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
|
// 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.
|
||||||
|
|||||||
+25
-15
@@ -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}>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user