Fix dead slow animated QR code reader

This commit is contained in:
minibits-cash
2026-09-20 21:24:55 +02:00
parent 31b74985ca
commit a96b22bcf9
4 changed files with 87 additions and 17 deletions
+52
View File
@@ -0,0 +1,52 @@
/**
* Animated QR (UR) throughput.
*
* Scanning a token used to take minutes because react-native-camera-kit defaults
* scanThrottleDelay to 2000 ms — one accepted part every 2 s. ScanScreen now passes
* scanThrottleDelay={0}, so the QRCode display interval is the only limit.
*
* This pins the part budget the timing estimate rests on: if the fountain encoder or
* the fragment length regresses, the wall-clock time goes up with it.
*/
import { UR, UREncoder, URDecoder } from '@gandlaf21/bc-ur'
// Must mirror src/screens/Wallet/QRCode.tsx.
const ANIMATED_QR_FRAGMENT_LENGTH = 150
const ANIMATED_QR_INTERVAL = 250
const partsToDecode = (payload: string) => {
const encoder = new UREncoder(UR.fromBuffer(Buffer.from(payload)), ANIMATED_QR_FRAGMENT_LENGTH, 0)
const decoder = new URDecoder()
let parts = 0
while (!decoder.isComplete()) {
decoder.receivePart(encoder.nextPart())
parts++
if (parts > 1000) throw new Error('decoder never completed')
}
expect(decoder.isSuccess()).toBe(true)
return {parts, decoded: Buffer.from(decoder.resultUR().decodeCBOR()).toString('utf8')}
}
test('a ~3 kB token round-trips within a few seconds of display time', () => {
const token = 'cashuB' + 'a'.repeat(3000)
const {parts, decoded} = partsToDecode(token)
expect(decoded).toBe(token)
// 3000 bytes / 150 = 20 fragments; the fountain encoder sends those in order first,
// so a lossless reader needs no more than a handful of extra parts.
expect(parts).toBeLessThanOrEqual(25)
// The number that actually matters to the user: seconds, not minutes.
expect(parts * ANIMATED_QR_INTERVAL).toBeLessThan(8000)
})
test('every displayed part is distinct, so the scanner-side dedupe never stalls', () => {
const encoder = new UREncoder(UR.fromBuffer(Buffer.from('x'.repeat(1500))), ANIMATED_QR_FRAGMENT_LENGTH, 0)
const seen = Array.from({length: 30}, () => encoder.nextPart())
// ScanScreen skips a read equal to the previous one. Consecutive repeats from the
// encoder would make that dedupe drop real parts.
seen.forEach((part, i) => expect(part).not.toBe(seen[i - 1]))
})
+5 -1
View File
@@ -7,13 +7,17 @@ module.exports = {
// The `(.*/)?` makes the allowlist apply at any depth: nostr-tools carries its // The `(.*/)?` makes the allowlist apply at any depth: nostr-tools carries its
// own ESM-only @noble copies in a nested node_modules, and those need babel too. // own ESM-only @noble copies in a nested node_modules, and those need babel too.
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!(.*/)?((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|react-native-flash-message)/)', 'node_modules/(?!(.*/)?((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure|cborg|react-native-flash-message)/)',
], ],
// Several native or source-shipped packages are unusable under jest, and the // Several native or source-shipped packages are unusable under jest, and the
// MODEL layer reaches all of them at import time (Mint -> services barrel -> // MODEL layer reaches all of them at import time (Mint -> services barrel ->
// mmkvStorage / keyChain / db). That is what made MST stores impossible to // mmkvStorage / keyChain / db). That is what made MST stores impossible to
// instantiate in a test at all; these mappings are what make store tests possible. // instantiate in a test at all; these mappings are what make store tests possible.
moduleNameMapper: { moduleNameMapper: {
// cborg (via @gandlaf21/bc-ur) is ESM-only and its exports map has an "import"
// condition only, which jest's CJS resolver cannot satisfy. Point at the entry
// file directly and let babel transform it (see transformIgnorePatterns above).
'^cborg$': '<rootDir>/node_modules/cborg/cborg.js',
// Same shape as the @scure/bip39 mapping below: the package's exports map // Same shape as the @scure/bip39 mapping below: the package's exports map
// names only the suffixed path, so app code imports it that way. This accepts // names only the suffixed path, so app code imports it that way. This accepts
// both spellings, for transitive dependencies still using the bare one. // both spellings, for transitive dependencies still using the bare one.
+26 -16
View File
@@ -41,10 +41,16 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
const {mintsStore} = useStores() const {mintsStore} = useStores()
const [shouldLoad, setShouldLoad] = useState<boolean>(false) const [shouldLoad, setShouldLoad] = useState<boolean>(false)
const [isScanned, setIsScanned] = useState<boolean>(false)
const [prevRouteName, setPrevRouteName] = useState<string>('') const [prevRouteName, setPrevRouteName] = useState<string>('')
const [urDecoder, setUrDecoder] = useState<URDecoder | undefined>(undefined)
const [urDecoderProgress, setUrDecoderProgress] = useState<number>(0) const [urDecoderProgress, setUrDecoderProgress] = useState<number>(0)
/*
* Refs, not state: with scanThrottleDelay={0} onReadCode fires on every camera
* frame (~30/s), so a state flag would still be stale on the next few frames and
* let a completed scan navigate several times.
*/
const urDecoder = useRef<URDecoder>(new URDecoder())
const isScanned = useRef<boolean>(false)
const lastPart = useRef<string>('')
const [unit, setUnit] = useState<MintUnit>('sat') const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined) const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>() const [error, setError] = useState<AppError | undefined>()
@@ -62,8 +68,6 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
setPrevRouteName(prevRoute) setPrevRouteName(prevRoute)
} }
const decoder = new URDecoder()
setUrDecoder(decoder)
} }
load() load()
}, []) }, [])
@@ -93,23 +97,28 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
const onReadCode = async function(event: any) { const onReadCode = async function(event: any) {
if (isScanned.current) { return }
const scanned = event.nativeEvent.codeStringValue const scanned = event.nativeEvent.codeStringValue
// The camera reads the same QR many times while it is on screen. Skipping the
// repeats keeps receivePart() and the progress re-render off the hot path.
if (scanned === lastPart.current) { return }
lastPart.current = scanned
if (scanned.toLowerCase().startsWith("ur:")) { if (scanned.toLowerCase().startsWith("ur:")) {
if(!urDecoder) { return } urDecoder.current.receivePart(scanned)
setUrDecoderProgress(Math.floor(urDecoder.current.estimatedPercentComplete() * 100))
urDecoder.receivePart(scanned) if (!urDecoder.current.isComplete()) {
setUrDecoderProgress(Math.floor(urDecoder.estimatedPercentComplete() * 100))
if (!urDecoder.isComplete()) {
return; return;
} }
if (urDecoder.isSuccess()) { if (urDecoder.current.isSuccess()) {
setIsScanned(true) isScanned.current = true
setUrDecoderProgress(0) setUrDecoderProgress(0)
const ur = urDecoder.resultUR() const ur = urDecoder.current.resultUR()
const decodedBuffer = ur.decodeCBOR() const decodedBuffer = ur.decodeCBOR()
const decodedData = Buffer.from(decodedBuffer).toString('utf8') const decodedData = Buffer.from(decodedBuffer).toString('utf8')
@@ -118,11 +127,11 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
return onIncomingData(decodedData) return onIncomingData(decodedData)
} else { } else {
setError(new AppError(Err.SCAN_ERROR, urDecoder.resultError())) setError(new AppError(Err.SCAN_ERROR, urDecoder.current.resultError()))
} }
} }
setIsScanned(true) isScanned.current = true
log.trace('Scanned', {scanned}) log.trace('Scanned', {scanned})
return onIncomingData(scanned) return onIncomingData(scanned)
@@ -307,7 +316,8 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
<Camera <Camera
cameraType={CameraType.Back} cameraType={CameraType.Back}
scanBarcode scanBarcode
onReadCode={(event: any) => (isScanned ? undefined : onReadCode(event))} scanThrottleDelay={0}
onReadCode={onReadCode}
style={{flex: 1}} style={{flex: 1}}
/> />
<View style={$bottomContainer}> <View style={$bottomContainer}>
+4
View File
@@ -27,6 +27,10 @@ import AppError, { Err } from '../../utils/AppError';
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress' export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress'
const ANIMATED_QR_FRAGMENT_LENGTH = 150 const ANIMATED_QR_FRAGMENT_LENGTH = 150
// ScanScreen passes scanThrottleDelay={0}, so our own scanner reads every camera
// frame and the display rate is the only limit. 4 fps is kept deliberately: it is
// what other cashu wallets animate at, and it survives a low-end camera that cannot
// decode a frame in less than ~200ms. Do not raise it without device testing.
const ANIMATED_QR_INTERVAL = 250 const ANIMATED_QR_INTERVAL = 250
export const QRCodeBlock = function (props: { export const QRCodeBlock = function (props: {