mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Fix dead slow animated QR code reader
This commit is contained in:
@@ -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
@@ -7,13 +7,17 @@ module.exports = {
|
||||
// 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.
|
||||
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
|
||||
// MODEL layer reaches all of them at import time (Mint -> services barrel ->
|
||||
// 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.
|
||||
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
|
||||
// names only the suffixed path, so app code imports it that way. This accepts
|
||||
// both spellings, for transitive dependencies still using the bare one.
|
||||
|
||||
+26
-16
@@ -41,10 +41,16 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
const {mintsStore} = useStores()
|
||||
|
||||
const [shouldLoad, setShouldLoad] = useState<boolean>(false)
|
||||
const [isScanned, setIsScanned] = useState<boolean>(false)
|
||||
const [prevRouteName, setPrevRouteName] = useState<string>('')
|
||||
const [urDecoder, setUrDecoder] = useState<URDecoder | undefined>(undefined)
|
||||
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 [mint, setMint] = useState<Mint | undefined>(undefined)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
@@ -62,8 +68,6 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
setPrevRouteName(prevRoute)
|
||||
}
|
||||
|
||||
const decoder = new URDecoder()
|
||||
setUrDecoder(decoder)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
@@ -93,23 +97,28 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
|
||||
|
||||
const onReadCode = async function(event: any) {
|
||||
if (isScanned.current) { return }
|
||||
|
||||
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(!urDecoder) { return }
|
||||
urDecoder.current.receivePart(scanned)
|
||||
setUrDecoderProgress(Math.floor(urDecoder.current.estimatedPercentComplete() * 100))
|
||||
|
||||
urDecoder.receivePart(scanned)
|
||||
setUrDecoderProgress(Math.floor(urDecoder.estimatedPercentComplete() * 100))
|
||||
|
||||
if (!urDecoder.isComplete()) {
|
||||
if (!urDecoder.current.isComplete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (urDecoder.isSuccess()) {
|
||||
setIsScanned(true)
|
||||
if (urDecoder.current.isSuccess()) {
|
||||
isScanned.current = true
|
||||
setUrDecoderProgress(0)
|
||||
|
||||
const ur = urDecoder.resultUR()
|
||||
const ur = urDecoder.current.resultUR()
|
||||
const decodedBuffer = ur.decodeCBOR()
|
||||
|
||||
const decodedData = Buffer.from(decodedBuffer).toString('utf8')
|
||||
@@ -118,11 +127,11 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
|
||||
return onIncomingData(decodedData)
|
||||
} 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})
|
||||
|
||||
return onIncomingData(scanned)
|
||||
@@ -307,7 +316,8 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
|
||||
<Camera
|
||||
cameraType={CameraType.Back}
|
||||
scanBarcode
|
||||
onReadCode={(event: any) => (isScanned ? undefined : onReadCode(event))}
|
||||
scanThrottleDelay={0}
|
||||
onReadCode={onReadCode}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<View style={$bottomContainer}>
|
||||
|
||||
@@ -27,6 +27,10 @@ import AppError, { Err } from '../../utils/AppError';
|
||||
export type QRCodeBlockTypes = 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC' | 'PUBKEY' | 'PaymentRequest' | 'BitcoinAddress'
|
||||
|
||||
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
|
||||
|
||||
export const QRCodeBlock = function (props: {
|
||||
|
||||
Reference in New Issue
Block a user