Skip to content

Commit 3a7d872

Browse files
Bulk scan ux parallelizable ocr against evidence matrix (#317)
* feat: add security policy contact details and create SECURITY-INSIGHTS file * feat: enhance BulkScannerScreen with QR code parsing and concurrency handling --------- Co-authored-by: kilodesodiq-arch <kilodesodiq@gmail.com>
1 parent eac7002 commit 3a7d872

2 files changed

Lines changed: 215 additions & 38 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { parseQRCode } from '../screens/BulkScannerScreen';
2+
3+
describe('BulkScannerScreen QR parsing', () => {
4+
it('extracts aidId from chainforge deep link', () => {
5+
expect(parseQRCode('chainforge://package/aid-001')).toBe('aid-001');
6+
});
7+
8+
it('returns null for non-chainforge URLs', () => {
9+
expect(parseQRCode('https://example.com/qr')).toBeNull();
10+
});
11+
12+
it('returns null for malformed input', () => {
13+
expect(parseQRCode('')).toBeNull();
14+
expect(parseQRCode('chainforge://')).toBeNull();
15+
expect(parseQRCode('chainforge://package/')).toBeNull();
16+
});
17+
});
18+
19+
describe('BulkScannerScreen concurrency', () => {
20+
const MAX_CONCURRENT = 4;
21+
22+
function createMockQueue(): {
23+
queue: string[];
24+
process: () => Promise<void>;
25+
} {
26+
const state = { queue: [] as string[], processing: 0, completed: 0 };
27+
return {
28+
queue: state.queue,
29+
process: async () => {
30+
state.queue.push('pending');
31+
},
32+
};
33+
}
34+
35+
it('allows up to MAX_CONCURRENT in-flight scans', () => {
36+
let inFlight = 0;
37+
let maxObserved = 0;
38+
39+
for (let i = 0; i < 50; i++) {
40+
if (inFlight < MAX_CONCURRENT) {
41+
inFlight++;
42+
maxObserved = Math.max(maxObserved, inFlight);
43+
// Simulate async completion
44+
inFlight--;
45+
}
46+
}
47+
48+
expect(maxObserved).toBe(MAX_CONCURRENT);
49+
});
50+
51+
it('throughput with concurrency ≥ 2× sequential on 50 items', async () => {
52+
const ITEM_COUNT = 50;
53+
const SIMULATED_LATENCY_MS = 10;
54+
55+
async function processSequential(ids: string[]): Promise<number> {
56+
const start = performance.now();
57+
for (const id of ids) {
58+
await new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS));
59+
}
60+
return performance.now() - start;
61+
}
62+
63+
async function processConcurrent(ids: string[]): Promise<number> {
64+
const start = performance.now();
65+
const chunks: string[][] = [];
66+
for (let i = 0; i < ids.length; i += MAX_CONCURRENT) {
67+
chunks.push(ids.slice(i, i + MAX_CONCURRENT));
68+
}
69+
for (const chunk of chunks) {
70+
await Promise.all(chunk.map(() => new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS))));
71+
}
72+
return performance.now() - start;
73+
}
74+
75+
const ids = Array.from({ length: ITEM_COUNT }, (_, i) => `aid-${i}`);
76+
77+
const sequentialMs = await processSequential(ids);
78+
const concurrentMs = await processConcurrent(ids);
79+
80+
const speedup = sequentialMs / concurrentMs;
81+
expect(speedup).toBeGreaterThanOrEqual(2);
82+
});
83+
84+
it('skips duplicate aidIds within dedup window', () => {
85+
const seen = new Map<string, number>();
86+
const DEDUP_TTL_MS = 5000;
87+
const results: string[] = [];
88+
89+
const scans = ['aid-1', 'aid-2', 'aid-1', 'aid-3', 'aid-2'];
90+
91+
for (const aidId of scans) {
92+
const now = Date.now();
93+
const lastSeen = seen.get(aidId);
94+
if (lastSeen && now - lastSeen < DEDUP_TTL_MS) {
95+
results.push('skipped');
96+
} else {
97+
seen.set(aidId, now);
98+
results.push('processed');
99+
}
100+
}
101+
102+
expect(results).toEqual(['processed', 'processed', 'skipped', 'processed', 'skipped']);
103+
});
104+
105+
it('rate-limits scans within RATE_LIMIT_MS window', () => {
106+
const RATE_LIMIT_MS = 300;
107+
const seen = new Map<string, number>();
108+
const results: string[] = [];
109+
110+
const now = Date.now();
111+
seen.set('aid-1', now);
112+
113+
// Immediate re-scan of same aidId
114+
const elapsed = Date.now() - (seen.get('aid-1') ?? 0);
115+
if (elapsed < RATE_LIMIT_MS) {
116+
results.push('rate-limited');
117+
} else {
118+
results.push('allowed');
119+
}
120+
121+
expect(results).toEqual(['rate-limited']);
122+
});
123+
});

app/mobile/src/screens/BulkScannerScreen.tsx

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import React, { useState, useEffect, useCallback } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
22
import {
33
Text,
44
View,
55
StyleSheet,
66
Dimensions,
77
TouchableOpacity,
8-
Alert,
98
ActivityIndicator,
109
} from 'react-native';
1110
import { BarCodeScanner } from 'expo-barcode-scanner';
@@ -27,17 +26,28 @@ interface SessionStats {
2726
skipped: number;
2827
}
2928

29+
const MAX_CONCURRENT = 4;
30+
const RATE_LIMIT_MS = 300;
31+
const DEDUP_TTL_MS = 5000;
32+
33+
export const parseQRCode = (data: string): string | null => {
34+
const match = data.match(/^chainforge:\/\/package\/(.+)$/);
35+
return match?.[1] ?? null;
36+
};
37+
3038
export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
3139
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
32-
const [isProcessing, setIsProcessing] = useState(false);
33-
const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error'; message: string } | null>(null);
40+
const [inFlightCount, setInFlightCount] = useState(0);
41+
const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error' | 'skipped'; message: string } | null>(null);
3442
const [stats, setStats] = useState<SessionStats>({
3543
scanned: 0,
3644
verified: 0,
3745
failed: 0,
3846
skipped: 0,
3947
});
4048

49+
const seenRef = useRef<Map<string, number>>(new Map());
50+
const resultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4151
const { colors } = useTheme();
4252
const { queueClaimConfirmation, isConnected } = useSync();
4353

@@ -50,45 +60,76 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
5060
getBarCodeScannerPermissions();
5161
}, []);
5262

53-
const handleBarCodeScanned = async ({ type, data }: { type: string; data: string }) => {
54-
if (isProcessing) return;
55-
setIsProcessing(true);
63+
useEffect(() => {
64+
return () => {
65+
if (resultTimerRef.current) clearTimeout(resultTimerRef.current);
66+
};
67+
}, []);
5668

57-
// Check if it's the correct format: chainforge://package/{id}
58-
const regex = /^chainforge:\/\/package\/(.+)$/;
59-
const match = data.match(regex);
69+
const showResult = useCallback(
70+
(result: { status: 'success' | 'error' | 'skipped'; message: string }) => {
71+
if (resultTimerRef.current) clearTimeout(resultTimerRef.current);
72+
setLastScanResult(result);
73+
resultTimerRef.current = setTimeout(() => setLastScanResult(null), 2000);
74+
},
75+
[],
76+
);
77+
78+
const handleBarCodeScanned = useCallback(
79+
async ({ type, data }: { type: string; data: string }) => {
80+
if (inFlightCount >= MAX_CONCURRENT) return;
6081

61-
setStats(prev => ({ ...prev, scanned: prev.scanned + 1 }));
82+
const aidId = parseQRCode(data);
6283

63-
if (match && match[1]) {
64-
const aidId = match[1];
65-
const claimId = `claim-${aidId}`; // Assuming standard claimId format for bulk verify
84+
if (!aidId) {
85+
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, failed: prev.failed + 1 }));
86+
showResult({ status: 'error', message: 'Invalid ChainForge QR code.' });
87+
return;
88+
}
89+
90+
const now = Date.now();
91+
const lastSeen = seenRef.current.get(aidId);
92+
93+
if (lastSeen && now - lastSeen < DEDUP_TTL_MS) {
94+
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 }));
95+
showResult({ status: 'skipped', message: 'Duplicate scan — skipped.' });
96+
return;
97+
}
98+
99+
if (lastSeen && now - lastSeen < RATE_LIMIT_MS) {
100+
setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 }));
101+
showResult({ status: 'skipped', message: 'Rate limited — slow down.' });
102+
return;
103+
}
104+
105+
seenRef.current.set(aidId, now);
106+
setStats(prev => ({ ...prev, scanned: prev.scanned + 1 }));
107+
setInFlightCount(prev => prev + 1);
108+
109+
const claimId = `claim-${aidId}`;
66110

67111
try {
68112
const result = await queueClaimConfirmation(aidId, claimId);
69-
113+
70114
if (result.status === 'completed' || result.status === 'queued') {
71115
setStats(prev => ({ ...prev, verified: prev.verified + 1 }));
72-
setLastScanResult({
73-
status: 'success',
74-
message: result.status === 'completed' ? 'Package verified successfully!' : 'Package queued for verification (offline).'
116+
showResult({
117+
status: 'success',
118+
message:
119+
result.status === 'completed'
120+
? 'Package verified successfully!'
121+
: 'Package queued for verification (offline).',
75122
});
76123
}
77-
} catch (error) {
124+
} catch {
78125
setStats(prev => ({ ...prev, failed: prev.failed + 1 }));
79-
setLastScanResult({ status: 'error', message: 'Verification failed. Please try again.' });
126+
showResult({ status: 'error', message: 'Verification failed. Please try again.' });
127+
} finally {
128+
setInFlightCount(prev => prev - 1);
80129
}
81-
} else {
82-
setStats(prev => ({ ...prev, failed: prev.failed + 1 }));
83-
setLastScanResult({ status: 'error', message: 'Invalid ChainForge QR code.' });
84-
}
85-
86-
// Short delay before allowing the next scan to provide feedback
87-
setTimeout(() => {
88-
setIsProcessing(false);
89-
setLastScanResult(null);
90-
}, 2000);
91-
};
130+
},
131+
[inFlightCount, queueClaimConfirmation, showResult],
132+
);
92133

93134
if (hasPermission === null) {
94135
return (
@@ -112,10 +153,12 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
112153
);
113154
}
114155

156+
const isAtCapacity = inFlightCount >= MAX_CONCURRENT;
157+
115158
return (
116159
<View style={styles.container}>
117160
<BarCodeScanner
118-
onBarCodeScanned={isProcessing ? undefined : handleBarCodeScanned}
161+
onBarCodeScanned={isAtCapacity ? undefined : handleBarCodeScanned}
119162
style={StyleSheet.absoluteFillObject}
120163
/>
121164

@@ -134,31 +177,42 @@ export const BulkScannerScreen: React.FC<Props> = ({ navigation }) => {
134177
<Text style={[styles.statValue, { color: colors.error }]}>{stats.failed}</Text>
135178
<Text style={styles.statLabel}>Failed</Text>
136179
</View>
180+
<View style={styles.statItem}>
181+
<Text style={[styles.statValue, { color: colors.warning ?? '#FFD700' }]}>{inFlightCount}</Text>
182+
<Text style={styles.statLabel}>In Flight</Text>
183+
</View>
137184
</View>
138185

139186
<View style={styles.viewfinderContainer}>
140-
<View style={[styles.viewfinder, isProcessing && styles.viewfinderProcessing]} />
187+
<View style={[styles.viewfinder, isAtCapacity && styles.viewfinderProcessing]} />
141188
</View>
142189

143190
{/* Feedback Area */}
144191
<View style={styles.feedbackContainer}>
145-
{isProcessing && !lastScanResult && (
192+
{isAtCapacity && !lastScanResult && (
146193
<View style={styles.processingIndicator}>
147194
<ActivityIndicator color="white" size="small" />
148-
<Text style={styles.processingText}>Processing...</Text>
195+
<Text style={styles.processingText}>Processing {inFlightCount}/{MAX_CONCURRENT}</Text>
149196
</View>
150197
)}
151198

152199
{lastScanResult && (
153200
<View style={[
154-
styles.resultBadge,
155-
{ backgroundColor: lastScanResult.status === 'success' ? colors.success : colors.error }
201+
styles.resultBadge,
202+
{
203+
backgroundColor:
204+
lastScanResult.status === 'success'
205+
? colors.success
206+
: lastScanResult.status === 'skipped'
207+
? (colors.warning ?? '#FFD700')
208+
: colors.error,
209+
},
156210
]}>
157211
<Text style={styles.resultText}>{lastScanResult.message}</Text>
158212
</View>
159213
)}
160214

161-
{!isProcessing && !lastScanResult && (
215+
{!isAtCapacity && !lastScanResult && (
162216
<Text style={styles.instructionText}>Align QR code to scan</Text>
163217
)}
164218

0 commit comments

Comments
 (0)