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 0e94768..95ae988 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()); @@ -504,6 +528,91 @@ describe('useKeypadCardReader on Android', () => { expect(pendingRequest()).toBeDefined(); }); + 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(() => { + if (pendingRequest()) { + return Promise.reject(new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE)); + } + const request = deferred(); + requests.push(request); + return request.promise; + }); + // 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()); + 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(); + + // 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(); + + expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(3); + expect(pendingRequest()).toBeDefined(); + 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(); @@ -750,11 +859,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/__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__/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/__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/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 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 95dc1fb..06c093e 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)) { @@ -343,9 +350,19 @@ 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. + // 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, + ) + .then(() => (gen === focusGenRef.current ? armLoop(gen) : undefined)); } return () => { focusGenRef.current++; 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 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) {