From 864b3a3b454c60ac1c28b8fdb64a2a50136489b5 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 14 Oct 2026 11:40:50 -0700 Subject: [PATCH 1/4] fix(keypad): ERR_MULTI_REQ on the keypad arm is busy, not a card failure Another screen's still-pending NFC request makes the native module refuse the keypad's requestTechnology with "You can only issue one request at a time". runSession toasted that literal string. Classify it with a new isMultiRequestError and return 'busy': no toast, and the loop re-arms after the existing breather. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- __tests__/hooks/useKeypadCardReader.test.tsx | 24 +++++++++++++++ __tests__/services/cashuCardNfc.test.ts | 32 ++++++++++++++++++++ src/hooks/useKeypadCardReader.ts | 7 +++++ src/services/cashuCardNfc.ts | 15 +++++++++ 4 files changed, 78 insertions(+) diff --git a/__tests__/hooks/useKeypadCardReader.test.tsx b/__tests__/hooks/useKeypadCardReader.test.tsx index 0e94768..2e91023 100644 --- a/__tests__/hooks/useKeypadCardReader.test.tsx +++ b/__tests__/hooks/useKeypadCardReader.test.tsx @@ -20,6 +20,7 @@ import { type CardSummary, } from '../../src/services/cashuCard'; import {BRIDGE_TAG, setCardBridge} from '../../src/services/cardBridge'; +import {MULTI_REQUEST_MESSAGE} from '../../src/services/cashuCardNfc'; const actualNfc = jest.requireActual('../../src/services/cashuCardNfc'); @@ -410,6 +411,29 @@ describe('useKeypadCardReader on Android', () => { ); }); + it('ERR_MULTI_REQ on arm is not a card failure: no toast, re-arms after a breather', async () => { + renderHook(() => useKeypadCardReader()); + focus(); + await flush(); + + // Another screen's request was still pending when the keypad armed: the + // native module refuses with its bare ERR_MULTI_REQ string. + await act(async () => { + pendingRequest()!.reject( + new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE), + ); + }); + await flush(); + + expect(mockToastShow).not.toHaveBeenCalled(); + expect(mockSetNfcBusy).toHaveBeenLastCalledWith(false); + await waitFor( + () => expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(2), + {timeout: REARM_AFTER_ERROR_MS * 4}, + ); + expect(mockToastShow).not.toHaveBeenCalled(); + }); + it('says quietly that a card without the applet is not a Flash card, and re-arms', async () => { mockReadCard.mockRejectedValue(new CardError(0x6a82, 'SELECT')); renderHook(() => useKeypadCardReader()); diff --git a/__tests__/services/cashuCardNfc.test.ts b/__tests__/services/cashuCardNfc.test.ts index d80e830..d44157b 100644 --- a/__tests__/services/cashuCardNfc.test.ts +++ b/__tests__/services/cashuCardNfc.test.ts @@ -6,8 +6,10 @@ import { cancelCardSession, describeCardFailure, extendCardTimeout, + isMultiRequestError, isUserCancel, isCardReadingSupported, + MULTI_REQUEST_MESSAGE, nfcTransceiver, openCardSession, readCardOverNfc, @@ -436,6 +438,36 @@ describe('isUserCancel', () => { }); }); +describe('isMultiRequestError', () => { + it('pins the native ERR_MULTI_REQ string (NfcManager.java:61)', () => { + expect(MULTI_REQUEST_MESSAGE).toBe( + 'You can only issue one request at a time', + ); + }); + + it('is true for the native error as the Android wrapper hands it over', () => { + expect( + isMultiRequestError(new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE)), + ).toBe(true); + expect(isMultiRequestError(new Error(MULTI_REQUEST_MESSAGE))).toBe(true); + }); + + it('is false for every other NFC failure', () => { + expect(isMultiRequestError(new NfcError.UserCancel())).toBe(false); + expect(isMultiRequestError(new NfcError.TagConnectionLost())).toBe(false); + expect( + isMultiRequestError( + new NfcError.NfcErrorBase('no tech request available'), + ), + ).toBe(false); + }); + + it('is false for non-errors, even one carrying the same text', () => { + expect(isMultiRequestError(MULTI_REQUEST_MESSAGE)).toBe(false); + expect(isMultiRequestError(null)).toBe(false); + }); +}); + describe('withCardSession timing', () => { // mockRestore also resets the recorded calls, so each test copies them out // before restoring the real console.log. diff --git a/src/hooks/useKeypadCardReader.ts b/src/hooks/useKeypadCardReader.ts index 95dc1fb..1c747c2 100644 --- a/src/hooks/useKeypadCardReader.ts +++ b/src/hooks/useKeypadCardReader.ts @@ -26,6 +26,7 @@ import { cancelCardSession, describeCardFailure, isCardReadingSupported, + isMultiRequestError, isUserCancel, openCardSession, usingCardBridge, @@ -297,6 +298,12 @@ export function useKeypadCardReader() { if (isUserCancel(error) || gen !== focusGenRef.current) { return 'cancelled'; } + // Another screen's request is still pending (ERR_MULTI_REQ): not a + // card failure, nothing to tell the merchant. Re-arm after the + // breather; the finally below clears the stray request. + if (isMultiRequestError(error)) { + return 'busy'; + } // A 0.x card: refused before anything is written or spent, and said // so plainly (decision 8) — never as "Not a Flash card". if (isCardNeedsUpdate(error)) { diff --git a/src/services/cashuCardNfc.ts b/src/services/cashuCardNfc.ts index ee49c54..ea76f41 100644 --- a/src/services/cashuCardNfc.ts +++ b/src/services/cashuCardNfc.ts @@ -361,6 +361,21 @@ export function isUserCancel(error: unknown): boolean { return typeof cls === 'function' && error instanceof cls; } +/** + * react-native-nfc-manager's Android `ERR_MULTI_REQ` (NfcManager.java:61): + * `requestTechnology` refuses while another request is still pending. The JS + * wrapper hands it over as a bare `NfcErrorBase` carrying this literal string. + */ +export const MULTI_REQUEST_MESSAGE = 'You can only issue one request at a time'; + +/** + * True when a request was refused because another screen's request is still + * pending (ENG-704). Not a card failure: there is nothing to tell a merchant. + */ +export function isMultiRequestError(error: unknown): boolean { + return error instanceof Error && error.message === MULTI_REQUEST_MESSAGE; +} + /** Turns any thrown value into something worth showing a merchant. */ export function describeCardFailure(error: unknown): string { if (error instanceof CardError) { From d491bc567b1f44ef65e8bb2343d1d6713cf04e12 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 14 Oct 2026 11:42:39 -0700 Subject: [PATCH 2/4] fix(keypad): flush a stray NFC request before the first arm on focus A popped card screen (CashuCardCharge, CashuCardSpend, CashuCardDebug, Invoice) cancels its request only on unmount, after its close transition. On the JS stack the keypad regains focus first, so its arm hit the live request and the native module refused it with ERR_MULTI_REQ, which the keypad toasted. Await a generation-guarded cancelCardSession() ahead of armLoop in the loop chain. The previous loop has already ended (the blur cancelled the keypad's own request), so this never cancels the keypad's own request; with nothing pending it is a no-op. The departing screen's late unmount cancel then hits the keypad's fresh request, which runSession reads as 'cancelled' and re-arms at once, silently; the new test locks that in. The ENG-630 write-ordering test now picks the session-close cancel as the first cancel after the arm, since the focus flush is the first cancel overall. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- __tests__/hooks/useKeypadCardReader.test.tsx | 52 ++++++++++++++++++-- src/hooks/useKeypadCardReader.ts | 15 ++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/__tests__/hooks/useKeypadCardReader.test.tsx b/__tests__/hooks/useKeypadCardReader.test.tsx index 2e91023..50625f4 100644 --- a/__tests__/hooks/useKeypadCardReader.test.tsx +++ b/__tests__/hooks/useKeypadCardReader.test.tsx @@ -528,6 +528,46 @@ describe('useKeypadCardReader on Android', () => { expect(pendingRequest()).toBeDefined(); }); + it('a focus right after a card screen leaves never toasts', async () => { + // Native-faithful for this test only: a request while another is pending + // is refused with ERR_MULTI_REQ (NfcManager.java:130-145). + mockNfc.requestTechnology.mockImplementation(() => { + if (pendingRequest()) { + return Promise.reject(new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE)); + } + const request = deferred(); + requests.push(request); + return request.promise; + }); + // The charge screen's request, still pending: it cancels only on + // unmount, after its close transition, so the keypad regains focus first. + mockNfc.requestTechnology(['IsoDep']).catch(() => {}); + + renderHook(() => useKeypadCardReader()); + focus(); + await flush(); + + // The keypad flushed the stray request before arming its own. + const firstCancel = + mockNfc.cancelTechnologyRequest.mock.invocationCallOrder[0]; + const keypadArm = mockNfc.requestTechnology.mock.invocationCallOrder[1]; + expect(firstCancel).toBeDefined(); + expect(keypadArm).toBeDefined(); + expect(firstCancel).toBeLessThan(keypadArm); + expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(2); + expect(pendingRequest()).toBeDefined(); + expect(mockToastShow).not.toHaveBeenCalled(); + + // The charge screen's late unmount cleanup cancels whatever is pending, + // which is now the keypad's request: silent, and re-armed at once. + await act(() => actualNfc.cancelCardSession()); + await flush(); + + expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(3); + expect(pendingRequest()).toBeDefined(); + expect(mockToastShow).not.toHaveBeenCalled(); + }); + it('never arms twice while one request is pending', async () => { const {result} = renderHook(() => useKeypadCardReader()); focus(); @@ -774,11 +814,17 @@ describe('useKeypadCardReader: change owed from an earlier charge (ENG-630)', () summary: after, changeAddedSat: 2, }); - // The write happened before the session closed. + // The write happened before the session closed. The session's close is + // the first cancel after the keypad armed; the one before it is the + // focus flush of any stray request (ENG-704). + const armOrder = mockNfc.requestTechnology.mock.invocationCallOrder[0]; const writeOrder = mockWriteOwedChange.mock.invocationCallOrder[0]; const cancelOrder = - mockNfc.cancelTechnologyRequest.mock.invocationCallOrder[0]; - expect(writeOrder).toBeLessThan(cancelOrder); + mockNfc.cancelTechnologyRequest.mock.invocationCallOrder.find( + order => order > armOrder, + ); + expect(cancelOrder).toBeDefined(); + expect(writeOrder).toBeLessThan(cancelOrder!); }); it('a write the store cut short before any LOAD claims nothing added: the screen opens with all of it still waiting', async () => { diff --git a/src/hooks/useKeypadCardReader.ts b/src/hooks/useKeypadCardReader.ts index 1c747c2..a75e379 100644 --- a/src/hooks/useKeypadCardReader.ts +++ b/src/hooks/useKeypadCardReader.ts @@ -350,9 +350,18 @@ export function useKeypadCardReader() { useCallback(() => { const gen = ++focusGenRef.current; if (Platform.OS === 'android') { - loopRef.current = loopRef.current.then(() => - gen === focusGenRef.current ? armLoop(gen) : undefined, - ); + // ENG-704: flush any request still pending before the first arm. A + // popped card screen (CashuCardCharge, CashuCardSpend, ...) cancels + // its own request only on unmount, after its close transition, so + // on the JS stack this focus lands while that request is live and + // our arm would hit ERR_MULTI_REQ. The previous loop has ended by + // now (the blur cancelled our own request), so this never cancels + // the keypad's own request; with nothing pending it is a no-op. + loopRef.current = loopRef.current + .then(() => + gen === focusGenRef.current ? cancelCardSession() : undefined, + ) + .then(() => (gen === focusGenRef.current ? armLoop(gen) : undefined)); } return () => { focusGenRef.current++; From 5bd70993095725dd83031908f48649f896fdd855 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 14 Oct 2026 11:55:39 -0700 Subject: [PATCH 3/4] fix(card-screens): release the NFC request from beforeRemove, not at unmount Review follow-up on ENG-704. A popped card screen cancelled its request only on unmount, at the end of the close transition. By then the keypad had armed its own request, and a card could be mid-read: the late cancel closed the keypad's tag under its transceive and toasted a card failure. useCardSessionRelease cancels as the stack lets the screen go, before the keypad's focus effect runs, and skips the unmount cancel once released (whatever is pending then belongs to the next screen). A removal with no beforeRemove still cancels at unmount. CashuCardCharge calls release from its own listener's let-through branches only, so a refused pop (held or settled) keeps the charge's session. Spend, Debug and Invoice use the hook's listener. The keypad's focus flush stays as the backstop. Its test no longer promises "never", and a new test pins what the merchant sees if a foreign cancel ever cuts a read mid-flight: a failed-read toast, then a re-arm. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- .../hooks/useCardSessionRelease.test.tsx | 76 +++++++++++++++++++ __tests__/hooks/useKeypadCardReader.test.tsx | 55 ++++++++++++-- __tests__/screens/CashuCardCharge.test.tsx | 60 +++++++++++++++ __tests__/screens/CashuCardDebug.test.tsx | 37 ++++++++- __tests__/screens/CashuCardSpend.test.tsx | 31 +++++++- __tests__/screens/Invoice.test.tsx | 30 +++++++- __tests__/support/navigationStub.ts | 38 ++++++++++ src/hooks/useCardSessionRelease.ts | 73 ++++++++++++++++++ src/hooks/useKeypadCardReader.ts | 15 ++-- src/screens/CashuCardCharge.tsx | 16 +++- src/screens/CashuCardDebug.tsx | 14 +++- src/screens/CashuCardSpend.tsx | 16 +++- src/screens/Invoice.tsx | 11 +-- 13 files changed, 440 insertions(+), 32 deletions(-) create mode 100644 __tests__/hooks/useCardSessionRelease.test.tsx create mode 100644 __tests__/support/navigationStub.ts create mode 100644 src/hooks/useCardSessionRelease.ts diff --git a/__tests__/hooks/useCardSessionRelease.test.tsx b/__tests__/hooks/useCardSessionRelease.test.tsx new file mode 100644 index 0000000..a8ff836 --- /dev/null +++ b/__tests__/hooks/useCardSessionRelease.test.tsx @@ -0,0 +1,76 @@ +import {renderHook} from '@testing-library/react-native'; + +import {useCardSessionRelease} from '../../src/hooks/useCardSessionRelease'; +import {navigationStub} from '../support/navigationStub'; + +const mockCancelCardSession = jest.fn(); + +jest.mock('../../src/services/cashuCardNfc', () => ({ + cancelCardSession: (...args: unknown[]) => mockCancelCardSession(...args), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockCancelCardSession.mockResolvedValue(undefined); +}); + +describe('useCardSessionRelease (ENG-704)', () => { + it('cancels as the stack lets the screen go, and not again at unmount', () => { + // The unmount lands at the end of the close transition, when the keypad + // underneath has re-armed and may be mid-read: a second cancel there + // would close the keypad's tag, not ours. + const navigation = navigationStub(); + const {unmount} = renderHook(() => useCardSessionRelease({navigation})); + + expect(mockCancelCardSession).not.toHaveBeenCalled(); + expect(navigation.emitBeforeRemove()).toBe(false); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + }); + + it('still cancels at unmount when the screen leaves without a beforeRemove', () => { + const {unmount} = renderHook(() => + useCardSessionRelease({navigation: navigationStub()}), + ); + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + }); + + it("marks the cut as the screen's own before cancelling", () => { + const order: string[] = []; + mockCancelCardSession.mockImplementation(async () => { + order.push('cancel'); + }); + const navigation = navigationStub(); + renderHook(() => + useCardSessionRelease({ + navigation, + onRelease: () => order.push('onRelease'), + }), + ); + navigation.emitBeforeRemove(); + expect(order).toEqual(['onRelease', 'cancel']); + }); + + it('without a navigation, hands release to the screen and installs no listener', () => { + const {result, unmount} = renderHook(() => useCardSessionRelease()); + + result.current(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + }); + + it('stops listening once unmounted', () => { + const navigation = navigationStub(); + const {unmount} = renderHook(() => useCardSessionRelease({navigation})); + unmount(); + mockCancelCardSession.mockClear(); + + navigation.emitBeforeRemove(); + expect(mockCancelCardSession).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/useKeypadCardReader.test.tsx b/__tests__/hooks/useKeypadCardReader.test.tsx index 50625f4..95ae988 100644 --- a/__tests__/hooks/useKeypadCardReader.test.tsx +++ b/__tests__/hooks/useKeypadCardReader.test.tsx @@ -528,7 +528,7 @@ describe('useKeypadCardReader on Android', () => { expect(pendingRequest()).toBeDefined(); }); - it('a focus right after a card screen leaves never toasts', async () => { + it('a focus that lands on a stray request flushes it and arms without a toast', async () => { // Native-faithful for this test only: a request while another is pending // is refused with ERR_MULTI_REQ (NfcManager.java:130-145). mockNfc.requestTechnology.mockImplementation(() => { @@ -539,8 +539,10 @@ describe('useKeypadCardReader on Android', () => { requests.push(request); return request.promise; }); - // The charge screen's request, still pending: it cancels only on - // unmount, after its close transition, so the keypad regains focus first. + // A card screen's request, still pending as the keypad regains focus: + // one removed without a `beforeRemove` (card screens release there, + // see useCardSessionRelease) cancels only on unmount, after its close + // transition. mockNfc.requestTechnology(['IsoDep']).catch(() => {}); renderHook(() => useKeypadCardReader()); @@ -558,8 +560,9 @@ describe('useKeypadCardReader on Android', () => { expect(pendingRequest()).toBeDefined(); expect(mockToastShow).not.toHaveBeenCalled(); - // The charge screen's late unmount cleanup cancels whatever is pending, - // which is now the keypad's request: silent, and re-armed at once. + // That screen's late unmount cleanup cancels whatever is pending, which + // is now the keypad's request. While no card is in the field it is + // silent, and re-armed at once. await act(() => actualNfc.cancelCardSession()); await flush(); @@ -568,6 +571,48 @@ describe('useKeypadCardReader on Android', () => { expect(mockToastShow).not.toHaveBeenCalled(); }); + it('a foreign cancel that cuts a read mid-flight is reported as a failed read', async () => { + // Decided (ENG-704): the keypad cannot tell another screen's late cancel + // from a card that left the field. Both close the tag under the + // transceive, so the merchant sees the read failed and taps again. Card + // screens release from `beforeRemove` so this cannot come from them. + const read = deferred(); + mockReadCard.mockReturnValue(read.promise); + renderHook(() => useKeypadCardReader()); + focus(); + await flush(); + await tap(V2_TAG); + await flush(); + expect(mockReadCard).toHaveBeenCalledTimes(1); + + // Native: cancelTechnologyRequest closes the connected tag, and the + // in-flight transceive fails with an I/O error, not UserCancel. + await act(() => actualNfc.cancelCardSession()); + const cut = new Error('transceive failed: tag connection lost'); + await act(async () => { + read.reject(cut); + }); + await flush(); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockToastShow).toHaveBeenCalledTimes(1); + expect(mockToastShow).toHaveBeenCalledWith({ + message: actualNfc.describeCardFailure(cut), + type: 'error', + }); + // Then it re-arms after the breather, ready for the next tap. + const arms = mockNfc.requestTechnology.mock.calls.length; + await waitFor( + () => + expect(mockNfc.requestTechnology.mock.calls.length).toBeGreaterThan( + arms, + ), + {timeout: REARM_AFTER_ERROR_MS * 4}, + ); + blur(); + await flush(); + }); + it('never arms twice while one request is pending', async () => { const {result} = renderHook(() => useKeypadCardReader()); focus(); diff --git a/__tests__/screens/CashuCardCharge.test.tsx b/__tests__/screens/CashuCardCharge.test.tsx index e4fd277..9ff66c8 100644 --- a/__tests__/screens/CashuCardCharge.test.tsx +++ b/__tests__/screens/CashuCardCharge.test.tsx @@ -1174,6 +1174,19 @@ describe('CashuCardCharge', () => { expect(navigation.emitBeforeRemove('RESET')).toBe(false); }); + it("releases only on Done's own reset, never on the pop it refused, and not again at unmount (ENG-704)", async () => { + const {navigation, unmount} = await settleViaRouter(); + const before = mockCancelCardSession.mock.calls.length; + await act(async () => { + expect(navigation.emitBeforeRemove('POP')).toBe(true); + }); + expect(mockCancelCardSession).toHaveBeenCalledTimes(before); + expect(navigation.emitBeforeRemove('RESET')).toBe(false); + expect(mockCancelCardSession).toHaveBeenCalledTimes(before + 1); + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(before + 1); + }); + it('before the swap, Back is still a plain pop', async () => { jest.useFakeTimers(FAKE_TIMERS); mockExecuteCharge.mockRejectedValue( @@ -1690,6 +1703,21 @@ describe('CashuCardCharge', () => { } }); + it('a refused pop releases nothing: the charge keeps its session', async () => { + const {navigation, ctl, navigatorBack} = await inFlight(); + try { + mockCancelCardSession.mockClear(); + expect(navigation.emitBeforeRemove('POP')).toBe(true); + expect(mockCancelCardSession).not.toHaveBeenCalled(); + await act(async () => { + ctl.succeed(); + }); + await expectHandedOffToSuccess(navigation); + } finally { + navigatorBack.remove(); + } + }); + it('a failure after the burn that is NOT paid releases the hold: Back leaves as before', async () => { const utils = await inFlight(); const {getByTestId, getByText, navigation, ctl, navigatorBack} = utils; @@ -1709,6 +1737,38 @@ describe('CashuCardCharge', () => { }); }); + // ENG-704: the unmount lands at the end of the close transition, when the + // keypad underneath has re-armed and a card may be mid-read there; a cancel + // at unmount would close the keypad's tag and toast a card failure. + it('a pop before the burn releases the session as the stack lets it go, quietly, and the unmount cancels nothing more', async () => { + let cut: (error: Error) => void = () => {}; + mockWithCardSession.mockImplementation( + () => + new Promise((_, reject) => { + cut = reject; + }), + ); + const {getByText, queryByTestId, navigation, unmount} = + await renderScreen(); + await act(async () => { + fireEvent.press(getByText('Tap card to charge')); + }); + expect(mockCancelCardSession).not.toHaveBeenCalled(); + + expect(navigation.emitBeforeRemove('POP')).toBe(false); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + + // The cancel cuts the read mid-APDU: a transceive error, not UserCancel. + // Still the merchant's own doing, never an error on the way out. + await act(async () => { + cut(new Error('transceive failed: tag connection lost')); + }); + expect(queryByTestId('error-pill')).toBeNull(); + + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + }); + it('cancels the NFC session on Cancel and on unmount', async () => { mockWithCardSession.mockImplementation(() => new Promise(() => {})); const {getByText, unmount} = await renderScreen(); diff --git a/__tests__/screens/CashuCardDebug.test.tsx b/__tests__/screens/CashuCardDebug.test.tsx index 6c8b783..27e908b 100644 --- a/__tests__/screens/CashuCardDebug.test.tsx +++ b/__tests__/screens/CashuCardDebug.test.tsx @@ -6,6 +6,7 @@ import {NfcError} from 'react-native-nfc-manager'; import CashuCardDebug from '../../src/screens/CashuCardDebug'; import {CardError, type CardSummary} from '../../src/services/cashuCard'; import {getCardBridge, setCardBridge} from '../../src/services/cardBridge'; +import {navigationStub} from '../support/navigationStub'; const mockCancelCardSession = jest.fn(); const mockIsCardReadingSupported = jest.fn(); @@ -73,9 +74,12 @@ beforeEach(() => { /** Renders and drains the `isCardReadingSupported` effect. */ async function renderScreen() { - const utils = render(); + const navigation = navigationStub(); + const utils = render( + , + ); await act(async () => {}); - return utils; + return {...utils, navigation}; } describe('CashuCardDebug session lifecycle', () => { @@ -218,6 +222,35 @@ describe('CashuCardDebug session lifecycle', () => { await waitFor(() => expect(getByText('NFC is turned off')).toBeTruthy()); }); + // ENG-704: the unmount lands at the end of the close transition, when the + // screen underneath (the keypad) may have armed its own request and be + // mid-read. The cancel must happen as the stack lets this screen go. + it('cancels an in-flight read as the stack lets the screen go, and not again at unmount', async () => { + const pending = deferred(); + mockReadCardOverNfc.mockReturnValue(pending.promise); + + const {getByText, queryByText, navigation, unmount} = await renderScreen(); + fireEvent.press(getByText('Read card')); + await act(async () => {}); + + expect(navigation.emitBeforeRemove()).toBe(false); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + + // The cut read surfaces as a transceive error, not UserCancel: still the + // merchant's own doing, never a red box on the way out. + const cut = new Error('transceive failed: tag connection lost'); + await act(async () => { + pending.reject(cut); + }); + const {describeCardFailure} = jest.requireActual( + '../../src/services/cashuCardNfc', + ); + expect(queryByText(describeCardFailure(cut))).toBeNull(); + + unmount(); + expect(mockCancelCardSession).toHaveBeenCalledTimes(1); + }); + it('cancels once on unmount even when no read was ever started', async () => { const {unmount} = await renderScreen(); unmount(); diff --git a/__tests__/screens/CashuCardSpend.test.tsx b/__tests__/screens/CashuCardSpend.test.tsx index 0b79a61..cec62e0 100644 --- a/__tests__/screens/CashuCardSpend.test.tsx +++ b/__tests__/screens/CashuCardSpend.test.tsx @@ -26,6 +26,7 @@ import { resetTestMint, type TestMint, } from '../support/cardProtocol2'; +import {navigationStub} from '../support/navigationStub'; const mockStore: Record = {}; @@ -112,7 +113,12 @@ async function needsCardPayment(card: FakeCard2) { } async function pressRecover() { - const utils = render(); + const utils = render( + , + ); await act(async () => {}); await act(async () => { fireEvent.press(utils.getByText(RECOVER)); @@ -180,3 +186,26 @@ describe('CashuCardSpend needs-card recovery', () => { ); }); }); + +describe('CashuCardSpend session release (ENG-704)', () => { + // The unmount lands at the end of the close transition, when the keypad + // underneath may have armed and be mid-read: the cancel must happen as the + // stack lets this screen go, and only then. + it('cancels as the stack lets the screen go, and not again at unmount', async () => { + const {cancelCardSession} = jest.requireMock( + '../../src/services/cashuCardNfc', + ) as {cancelCardSession: jest.Mock}; + const navigation = navigationStub(); + const {unmount} = render( + , + ); + await act(async () => {}); + cancelCardSession.mockClear(); + + expect(navigation.emitBeforeRemove()).toBe(false); + expect(cancelCardSession).toHaveBeenCalledTimes(1); + + unmount(); + expect(cancelCardSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/screens/Invoice.test.tsx b/__tests__/screens/Invoice.test.tsx index 8b154ca..868a0db 100644 --- a/__tests__/screens/Invoice.test.tsx +++ b/__tests__/screens/Invoice.test.tsx @@ -5,6 +5,8 @@ import {configureStore} from '@reduxjs/toolkit'; import Toast from 'react-native-toast-message'; import Invoice from '../../src/screens/Invoice'; import rootReducer from '../../src/store/reducers'; +import NfcManager from 'react-native-nfc-manager'; +import {navigationStub} from '../support/navigationStub'; const mockUseSubscription = jest.fn(); const mockUseLazyQuery = jest.fn(); @@ -193,10 +195,10 @@ const renderInvoice = ({reward}: RenderOptions = {}) => { }, } as any, }); - const navigation = { + const navigation = navigationStub({ replace: jest.fn(), goBack: jest.fn(), - }; + }); const tree = () => ( @@ -541,6 +543,30 @@ describe('Invoice screen Flashcard withdraw callback', () => { } }); + // ENG-704: the unmount lands at the end of the close transition, when the + // keypad underneath may have armed its own request and be mid-read. The + // router session is released as the stack lets the Invoice go instead. + it('releases the card session as the stack lets the screen go, and not again at unmount', () => { + const cancel = NfcManager.cancelTechnologyRequest as jest.Mock; + const {navigation, unmount} = renderInvoice(); + cancel.mockClear(); + + expect(navigation.emitBeforeRemove()).toBe(false); + expect(cancel).toHaveBeenCalledTimes(1); + + unmount(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('still cancels the card session at unmount when no beforeRemove came first', () => { + const cancel = NfcManager.cancelTechnologyRequest as jest.Mock; + const {unmount} = renderInvoice(); + cancel.mockClear(); + + unmount(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + describe('card state after the callback', () => { const cardOnContext = () => ({ ...tappedFlashcard(), diff --git a/__tests__/support/navigationStub.ts b/__tests__/support/navigationStub.ts new file mode 100644 index 0000000..a2e8cd6 --- /dev/null +++ b/__tests__/support/navigationStub.ts @@ -0,0 +1,38 @@ +/** + * A stack-screen `navigation` prop with a working `beforeRemove` channel, + * for screens that release their card session as the stack lets them go + * (ENG-704). `emitBeforeRemove()` fires the event as a pop does and says + * whether any listener refused it. + */ +type BeforeRemoveEvent = { + preventDefault: () => void; + data: {action: {type: string}}; +}; +type BeforeRemoveListener = (e: BeforeRemoveEvent) => void; + +export function navigationStub(extra: T = {} as T) { + const listeners: BeforeRemoveListener[] = []; + return { + ...extra, + addListener: jest.fn((event: string, cb: BeforeRemoveListener) => { + if (event !== 'beforeRemove') { + return () => {}; + } + listeners.push(cb); + return () => { + const at = listeners.indexOf(cb); + if (at >= 0) { + listeners.splice(at, 1); + } + }; + }), + /** Fires `beforeRemove` as the stack does for a pop; true = prevented. */ + emitBeforeRemove: (type = 'GO_BACK') => { + let prevented = false; + for (const cb of [...listeners]) { + cb({preventDefault: () => (prevented = true), data: {action: {type}}}); + } + return prevented; + }, + }; +} diff --git a/src/hooks/useCardSessionRelease.ts b/src/hooks/useCardSessionRelease.ts new file mode 100644 index 0000000..ccc3e26 --- /dev/null +++ b/src/hooks/useCardSessionRelease.ts @@ -0,0 +1,73 @@ +import {useCallback, useEffect, useRef} from 'react'; + +import {cancelCardSession} from '../services/cashuCardNfc'; + +/** The one navigator event this hook listens on. */ +export interface BeforeRemoveSource { + addListener(type: 'beforeRemove', callback: () => void): () => void; +} + +export interface CardSessionReleaseOptions { + /** + * Installs a `beforeRemove` listener that releases the session as soon as + * the stack lets this screen go. Leave it out when the screen has its own + * `beforeRemove` listener that may refuse the removal, and call the + * returned `release` from that listener's let-through branches instead. + */ + navigation?: BeforeRemoveSource | null; + /** Runs inside `release`, before the cancel: mark the cut as the screen's own. */ + onRelease?: () => void; +} + +/** + * Ends a card screen's IsoDep request when the screen leaves, not after. + * + * A pending request swallows every BoltCard tap app-wide, so every card + * screen must cancel it on the way out. An unmount cleanup alone runs too + * late: the stack unmounts a popped screen at the END of its close + * transition (about 350 to 400 ms), and by then the screen underneath has + * focus. When that is the keypad, it has armed its own request and a card + * may already be mid-read; `cancelCardSession` cancels whatever is pending, + * which closes the keypad's tag under its transceive and toasts a card + * failure (ENG-704). + * + * `release` cancels at the source instead: from `beforeRemove`, before the + * screen underneath is focused, so the next screen arms against a clear + * radio. Once released, the unmount cleanup does nothing: whatever is + * pending by then belongs to the next screen. A screen removed without a + * `beforeRemove` (a parent unmounting, a test) still cancels on unmount. + * + * Returns `release`, stable across renders. + */ +export function useCardSessionRelease({ + navigation, + onRelease, +}: CardSessionReleaseOptions = {}): () => void { + const releasedRef = useRef(false); + const onReleaseRef = useRef(onRelease); + onReleaseRef.current = onRelease; + + const release = useCallback(() => { + releasedRef.current = true; + onReleaseRef.current?.(); + cancelCardSession(); + }, []); + + useEffect(() => { + if (!navigation) { + return; + } + return navigation.addListener('beforeRemove', release); + }, [navigation, release]); + + useEffect( + () => () => { + if (!releasedRef.current) { + cancelCardSession(); + } + }, + [], + ); + + return release; +} diff --git a/src/hooks/useKeypadCardReader.ts b/src/hooks/useKeypadCardReader.ts index a75e379..06c093e 100644 --- a/src/hooks/useKeypadCardReader.ts +++ b/src/hooks/useKeypadCardReader.ts @@ -350,13 +350,14 @@ export function useKeypadCardReader() { useCallback(() => { const gen = ++focusGenRef.current; if (Platform.OS === 'android') { - // ENG-704: flush any request still pending before the first arm. A - // popped card screen (CashuCardCharge, CashuCardSpend, ...) cancels - // its own request only on unmount, after its close transition, so - // on the JS stack this focus lands while that request is live and - // our arm would hit ERR_MULTI_REQ. The previous loop has ended by - // now (the blur cancelled our own request), so this never cancels - // the keypad's own request; with nothing pending it is a no-op. + // ENG-704: flush any request still pending before the first arm. + // Card screens release their own request from `beforeRemove` + // (useCardSessionRelease), before this focus lands; this is the + // backstop for one that did not (a screen removed without the + // event, a session opened in the gap), whose request would make our + // arm hit ERR_MULTI_REQ. The previous loop has ended by now (the + // blur cancelled our own request), so this never cancels the + // keypad's own request; with nothing pending it is a no-op. loopRef.current = loopRef.current .then(() => gen === focusGenRef.current ? cancelCardSession() : undefined, diff --git a/src/screens/CashuCardCharge.tsx b/src/screens/CashuCardCharge.tsx index 84d51af..973ed43 100644 --- a/src/screens/CashuCardCharge.tsx +++ b/src/screens/CashuCardCharge.tsx @@ -40,6 +40,7 @@ import { type StageState, } from '../components/cashu/charge/phaseToStation'; import {useStallTimer} from '../components/cashu/charge/useStallTimer'; +import {useCardSessionRelease} from '../hooks/useCardSessionRelease'; // utils import {formatSatAmount} from '../utils/satCurrency'; @@ -191,12 +192,15 @@ const CashuCardCharge = ({navigation, route}: Props) => { }, []); // A pending IsoDep request swallows every BoltCard tap app-wide (see - // docs/13-cashu-card.md) — leaving mid-read must cancel it. + // docs/13-cashu-card.md) — leaving mid-read must cancel it. The + // `beforeRemove` listener below releases it as the stack lets the screen + // go, before the keypad underneath re-arms (ENG-704); the hook's unmount + // cleanup covers a removal that never asked. + const releaseCardSession = useCardSessionRelease(); useEffect( () => () => { cancelledRef.current = true; unmountedRef.current = true; - cancelCardSession(); }, [], ); @@ -588,6 +592,7 @@ const CashuCardCharge = ({navigation, route}: Props) => { () => navigation.addListener('beforeRemove', e => { if (leavingRef.current) { + releaseCardSession(); return; } if (settledRef.current) { @@ -597,9 +602,14 @@ const CashuCardCharge = ({navigation, route}: Props) => { } if (holdingRef.current) { e.preventDefault(); + return; } + // A plain pop before the burn: the read it cuts is the merchant's + // own doing, not a card failure to paint on the way out. + cancelledRef.current = true; + releaseCardSession(); }), - [navigation, onDone], + [navigation, onDone, releaseCardSession], ); // The header arrow: Done once paid, nothing while the charge is in flight diff --git a/src/screens/CashuCardDebug.tsx b/src/screens/CashuCardDebug.tsx index 42bf975..0f61e61 100644 --- a/src/screens/CashuCardDebug.tsx +++ b/src/screens/CashuCardDebug.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, ScrollView, TextInput} from 'react-native'; import styled from 'styled-components/native'; +import {StackScreenProps} from '@react-navigation/stack'; // components // Imported from the leaf, not the `../components` barrel: the barrel pulls in @@ -18,6 +19,9 @@ import { } from '../services/cashuCardNfc'; import type {CardSummary} from '../services/cashuCard'; import {getCardBridge, setCardBridge} from '../services/cardBridge'; // __DEV__-only screen; null in release +import {useCardSessionRelease} from '../hooks/useCardSessionRelease'; + +type Props = StackScreenProps; const contentStyle = {padding: 20}; const readButtonStyle = {marginTop: 24, marginBottom: 16}; @@ -40,7 +44,7 @@ const BRIDGE_PLACEHOLDER = 'http://127.0.0.1:9876'; * cashu-javacard repo. If this screen shows a pubkey, the applet is installed, * the AID selects over NFC, and the card's crypto is answering. */ -const CashuCardDebug = () => { +const CashuCardDebug = ({navigation}: Props) => { const [supported, setSupported] = useState(null); const [reading, setReading] = useState(false); const [summary, setSummary] = useState(null); @@ -86,10 +90,16 @@ const CashuCardDebug = () => { // An IsoDep request never times out on Android — it stays pending until a // card arrives. Leaving this screen mid-read would strand it, and a pending // session swallows every BoltCard tap app-wide, on the live payment path. + // Released as the stack lets the screen go, not at unmount (ENG-704). + useCardSessionRelease({ + navigation, + onRelease: () => { + cancelledRef.current = true; + }, + }); useEffect( () => () => { cancelledRef.current = true; - cancelCardSession(); }, [], ); diff --git a/src/screens/CashuCardSpend.tsx b/src/screens/CashuCardSpend.tsx index 94e30e0..f1955b0 100644 --- a/src/screens/CashuCardSpend.tsx +++ b/src/screens/CashuCardSpend.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, ScrollView, TextInput} from 'react-native'; import styled from 'styled-components/native'; +import {StackScreenProps} from '@react-navigation/stack'; // components // Imported from the leaf, not the `../components` barrel — same reasoning as @@ -35,6 +36,9 @@ import { } from '../services/cashuSettlement'; import {FLASH_CASHU_MINT_URL} from '@env'; import {toastShow} from '../utils/toast'; +import {useCardSessionRelease} from '../hooks/useCardSessionRelease'; + +type Props = StackScreenProps; const contentStyle = {padding: 20}; const readButtonStyle = {marginTop: 24, marginBottom: 8}; @@ -63,7 +67,7 @@ const PayoutInput = styled(TextInput)` * * CashuCardDebug is the read-only bring-up harness; this one moves money. */ -const CashuCardSpend = () => { +const CashuCardSpend = ({navigation}: Props) => { const {username} = useAppSelector(state => state.user); const [supported, setSupported] = useState(null); // `setSpending` went away with the in-session settle; the flag stays wired @@ -104,11 +108,17 @@ const CashuCardSpend = () => { }, [refreshQueue]); // A pending IsoDep request swallows every BoltCard tap app-wide (see - // docs/13-cashu-card.md) — leaving this screen mid-read must cancel it. + // docs/13-cashu-card.md) — leaving this screen mid-read must cancel it, + // and as the stack lets it go, not at unmount (ENG-704). + useCardSessionRelease({ + navigation, + onRelease: () => { + cancelledRef.current = true; + }, + }); useEffect( () => () => { cancelledRef.current = true; - cancelCardSession(); }, [], ); diff --git a/src/screens/Invoice.tsx b/src/screens/Invoice.tsx index 882d287..b2f38ba 100644 --- a/src/screens/Invoice.tsx +++ b/src/screens/Invoice.tsx @@ -21,6 +21,7 @@ import {ActivityIndicator} from '../contexts/ActivityIndicator'; // hooks import {useCardPaymentRouter} from '../hooks/useCardPaymentRouter'; import {useFlashcard} from '../hooks'; +import {useCardSessionRelease} from '../hooks/useCardSessionRelease'; import {cancelCardSession} from '../services/cashuCardNfc'; import CardTapSheet from '../components/cashu/CardTapSheet'; import {useAppDispatch, useAppSelector} from '../store/hooks'; @@ -96,13 +97,9 @@ const Invoice: React.FC = ({navigation}) => { const {routeCardPayment, isScanning} = useCardPaymentRouter(); // A router session left armed when the merchant leaves this screen swallows - // every later BoltCard tap app-wide — end it with the screen. - useEffect( - () => () => { - cancelCardSession(); - }, - [], - ); + // every later BoltCard tap app-wide — end it as the stack lets the screen + // go, before the keypad underneath re-arms (ENG-704). + useCardSessionRelease({navigation}); useEffect(() => { // Reset on every mount, not only the first: StrictMode and Fast Refresh From a8cef6f56b7a855e3dcc2bb0f53982285628acad Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 14 Oct 2026 12:18:56 -0700 Subject: [PATCH 4/4] docs(cashu-card): card screens release the NFC request from beforeRemove Review follow-up on ENG-704. docs/13-cashu-card.md still told screens to cancel their session on unmount, which 5bd7099 replaced: an unmount cancel lands after the keypad underneath has armed, and closes its tag mid-read. Point screens at useCardSessionRelease, say why, and note the CashuCardCharge pattern for a listener that can refuse the pop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- docs/13-cashu-card.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/13-cashu-card.md b/docs/13-cashu-card.md index 333586b..0af1f46 100644 --- a/docs/13-cashu-card.md +++ b/docs/13-cashu-card.md @@ -74,8 +74,16 @@ Two defences, both required: `cancelCardSession()`. This covers the read completing or failing. - `cancelCardSession()` is also exported for callers to invoke directly, because that `finally` **cannot run** while the request is still pending. Any screen - that can start a session must cancel it on unmount (`CashuCardDebug` does) and - should offer a visible Cancel control while a read is in flight. + that can start a session must cancel it when it leaves, and should offer a + visible Cancel control while a read is in flight. Use + `useCardSessionRelease` (`src/hooks/useCardSessionRelease.ts`): it cancels + from `beforeRemove`, as the stack lets the screen go, and falls back to an + unmount cancel only when no `beforeRemove` came first. An unmount cancel + alone runs too late: the stack unmounts a popped screen at the end of its + close transition, after the keypad underneath has armed its own request, so + the cancel would close the keypad's tag mid-read (ENG-704). A screen with its + own `beforeRemove` listener that can refuse the pop (`CashuCardCharge`) calls + the returned `release` from its let-through branches only. Note we are **not** in Android reader mode. `FlashcardProvider` already calls `registerTagEvent()` with no options at app start, so `isReaderModeEnabled` is