Files
alarm-client/src/screens/ScannerScreen.tsx
T
Admin b32df04ab9 [fix](client): scan QR codes with MLKit's barcode output, not the iOS-only object output
Tapping "Scan a QR code" crashed the app on Android:

    com.facebook.react.common.JavascriptException: Error:
    CameraFactory.createObjectOutput(...): java.lang.Error:
    CameraObjectOutput is not available on Android!

`useObjectOutput` is Apple Vision only — the comment claiming v5 ships a
cross-platform code scanner in core was wrong, and the native factory throws as
soon as the screen mounts. The Barcode Scanner output is the cross-platform one
(MLKit on Android, Vision on iOS); it is attached to the same `<Camera>` via
`outputs`, scans `qr-code` only, and keeps the hand-paste fallback. The scan
callback now takes the barcode values directly instead of the object-output
shape.
2026-10-19 15:18:31 +01:00

393 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AccessibilityInfo,
Animated,
Easing,
Keyboard,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Camera,
useCameraDevice,
useCameraPermission,
} from 'react-native-vision-camera';
import { useBarcodeScannerOutput } from 'react-native-vision-camera-barcode-scanner';
import { ScanLine, X } from 'lucide-react-native';
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
import { useAlarm } from '../state/AlarmContext';
import { joinErrorMessage } from '../services/alarmApi';
import { buildDeviceLabel } from '../lib/deviceLabel';
import { canDeliverRealAlarm } from '../lib/takeoverReadiness';
import { useAlarmPermissions } from '../state/useAlarmPermissions';
import { parseQrPayload, parseManualToken, qrErrorMessage } from '../lib/qrPayload';
import { openAppSettings } from '../services/alarmNative';
import type { RootStackParamList } from '../navigation';
import {
bg,
border,
focusRing,
minTapTarget,
motionSiren,
radiusFull,
scrim,
stateIdle,
space2,
space3,
space4,
surface,
text,
textMuted,
textSecondary,
typeBodyStrong,
typeCaption,
typeHeading,
typeLabel,
warn,
} from '../theme/tokens';
/** §7.5: the scan window is 280×280 with 3px corner brackets. */
const VIEWFINDER = 280;
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Rt = RouteProp<RootStackParamList, 'Scanner'>;
/**
* QR scanner.
*
* The camera is `react-native-vision-camera` 5.x, which ships the object
* scanner in core (`useObjectOutput({types:['qr']})`) — there is no separate
* code-scanner plugin in v5. The manual paste field below is always available,
* including when the camera is denied or unavailable, because a subscribe token
* is a plain string.
*/
export default function ScannerScreen() {
const navigation = useNavigation<Nav>();
const { params } = useRoute<Rt>();
const { joinAlert } = useAlarm();
const { hasPermission, requestPermission, canRequestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const [joinedName, setJoinedName] = useState<string | null>(null);
const handled = useRef(false);
const [reduceMotion, setReduceMotion] = useState(false);
const sweepValue = useRef(new Animated.Value(0)).current;
useEffect(() => {
let mounted = true;
AccessibilityInfo.isReduceMotionEnabled()
.then(enabled => mounted && setReduceMotion(enabled))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
return () => {
mounted = false;
sub.remove();
};
}, []);
// The scan line is the affordance that says the scanner is working. Under
// reduce-motion it parks in the middle instead of sweeping.
useEffect(() => {
if (reduceMotion) {
sweepValue.setValue(0.5);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(sweepValue, {
toValue: 1,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sweepValue, {
toValue: 0,
duration: motionSiren,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [reduceMotion, sweepValue]);
const targetAlertId = params?.alertId;
const { perms } = useAlarmPermissions();
const complete = useCallback(
async (alertId: string, token: string) => {
if (busy || handled.current) return;
// Subscribing on a device that cannot fire a real alarm would present as
// armed while being unable to ring. Block it and name the fix.
if (perms && !canDeliverRealAlarm(perms)) {
setError(
'This device cannot deliver a real alarm yet. Allow exact alarms and the full-screen takeover in Settings, then scan again.',
);
return;
}
handled.current = true;
setBusy(true);
setError(null);
try {
const record = await joinAlert({
alertId,
subscribeToken: token,
// Device *role*, never a slice of the identity token.
deviceLabel: buildDeviceLabel(),
});
setJoinedName(record.name || alertId);
// Give the confirmation a beat, then return to the detail screen.
setTimeout(() => {
navigation.replace('AlertDetail', { alertId, role: 'subscriber' });
}, 700);
} catch (e) {
handled.current = false;
setError(joinErrorMessage(e));
} finally {
setBusy(false);
}
},
[busy, joinAlert, navigation, perms],
);
// Both platforms scan through MLKit here. The core library's
// `useObjectOutput` is Apple-Vision only: on Android its native factory
// throws `CameraObjectOutput is not available on Android!` the moment the
// screen mounts, which crashed the app on "Scan a QR code". The Barcode
// Scanner output is cross-platform (MLKit on Android, Vision on iOS).
const handleScanValue = useCallback(
(value: string) => {
const parsed = parseQrPayload(value);
if (parsed.ok) {
if (targetAlertId && parsed.payload.alertId !== targetAlertId) {
setError('That code is for a different alert.');
return;
}
complete(parsed.payload.alertId, parsed.payload.subscribeToken);
} else {
setError(qrErrorMessage(parsed.error));
}
},
[complete, targetAlertId],
);
const barcodeOutput = useBarcodeScannerOutput({
barcodeFormats: ['qr-code'],
onBarcodeScanned(barcodes) {
for (const barcode of barcodes) {
const value = barcode.rawValue;
if (typeof value === 'string' && value.length > 0) {
handleScanValue(value);
return;
}
}
},
onError(error) {
setError('The camera could not start scanning. You can paste the code by hand.');
console.log('barcode scanner error', error.message);
},
});
const submitManual = async () => {
Keyboard.dismiss();
const trimmed = manual.trim();
if (!trimmed) return;
// Accept a full link, a scheme link, or a bare token when we know the alert.
const parsed = parseQrPayload(trimmed);
if (parsed.ok) {
await complete(parsed.payload.alertId, parsed.payload.subscribeToken);
return;
}
// A bare token can only be resolved when we arrived here from an alert.
if (targetAlertId) {
const byToken = parseManualToken(trimmed, targetAlertId);
if (byToken.ok) {
await complete(byToken.payload.alertId, byToken.payload.subscribeToken);
return;
}
}
setError(qrErrorMessage(parsed.error));
};
const sweep = sweepValue.interpolate({
inputRange: [0, 1],
outputRange: [-VIEWFINDER / 2, VIEWFINDER / 2],
});
return (
<View style={styles.root}>
<Header
title="Scan alert code"
subtitle="Point the camera at the QR code the owner shows"
onBack={() => navigation.goBack()}
right={
<Pressable
onPress={() => navigation.goBack()}
accessibilityRole="button"
accessibilityLabel="Close scanner"
style={styles.iconButton}
>
<X size={20} color={textMuted} />
</Pressable>
}
/>
<View style={styles.cameraWrap}>
{hasPermission && device ? (
<Camera
style={StyleSheet.absoluteFill}
device={device}
isActive={!joinedName}
outputs={[barcodeOutput]}
/>
) : (
<View style={styles.permission}>
<ScanLine size={40} color={textMuted} />
<AppText style={{ ...typeHeading, color: text, marginTop: space3 }}>
Camera access is off
</AppText>
<AppText style={styles.para}>
The camera is only used to read alert codes. You can also paste the code by hand.
</AppText>
{canRequestPermission ? (
<Button label="Allow camera" onPress={requestPermission} />
) : (
<>
<AppText style={styles.para}>
Camera permission was denied. Turn it on in Android Settings → Apps → Alarm →
Permissions.
</AppText>
<Button label="Open settings" variant="secondary" onPress={() => { openAppSettings(); }} />
</>
)}
<Button label="Enter code by hand" variant="outlineWhite" onPress={() => setShowManual(true)} />
</View>
)}
{hasPermission && !joinedName ? (
<View pointerEvents="none" style={styles.reticleWrap}>
<View style={styles.viewfinder}>
{/* Four 3px corner brackets: "aim inside this frame" without a
box that fights the camera image (DESIGN.md §7.5). */}
<View style={[styles.bracket, styles.bracketTL]} />
<View style={[styles.bracket, styles.bracketTR]} />
<View style={[styles.bracket, styles.bracketBL]} />
<View style={[styles.bracket, styles.bracketBR]} />
<Animated.View
style={[
styles.scanLine,
reduceMotion ? styles.scanLineRest : { transform: [{ translateY: sweep }] },
]}
/>
</View>
<AppText style={styles.reticleHint}>Keep the whole code inside the frame</AppText>
</View>
) : null}
{joinedName ? (
<View style={styles.joined}>
<AppText style={{ ...typeHeading, color: text }}>Subscribed</AppText>
<AppText style={styles.para}>This device will ring for {joinedName}.</AppText>
</View>
) : null}
</View>
<View style={styles.sheet}>
{error ? <ErrorBanner message={error} /> : null}
{showManual || !hasPermission || !device ? (
<View style={{ gap: space3 }}>
<Field label="Alert code or link">
<TextInputBox
value={manual}
onChangeText={setManual}
autoCapitalize="none"
autoCorrect={false}
placeholder="/api/alarm/a/…#token or https://your-instance/…"
accessibilityLabel="Alert code or link"
/>
</Field>
<Button
label={busy ? 'Subscribing…' : 'Subscribe this device'}
onPress={submitManual}
disabled={busy || !manual.trim()}
/>
</View>
) : (
<Button label="Enter code by hand" variant="secondary" onPress={() => setShowManual(true)} />
)}
{targetAlertId ? (
<AppText style={styles.target}>Subscribing to {targetAlertId}</AppText>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: bg },
iconButton: {
width: minTapTarget,
height: minTapTarget,
alignItems: 'center',
justifyContent: 'center',
},
cameraWrap: { flex: 1, backgroundColor: '#000000', justifyContent: 'center' },
permission: { padding: space4, alignItems: 'center', gap: space2 },
para: { ...typeCaption, color: textSecondary, textAlign: 'center', marginBottom: space2 },
reticleWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
viewfinder: { width: VIEWFINDER, height: VIEWFINDER, justifyContent: 'center' },
bracket: {
position: 'absolute',
width: 40,
height: 40,
borderColor: stateIdle,
borderWidth: 3,
},
bracketTL: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
bracketTR: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
bracketBL: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
bracketBR: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
scanLine: {
height: 2,
marginHorizontal: 8,
backgroundColor: focusRing,
},
scanLineRest: { position: 'absolute', top: '50%', left: 0, right: 0 },
reticleHint: {
...typeCaption,
color: text,
marginTop: space3,
backgroundColor: scrim,
paddingHorizontal: space3,
paddingVertical: space2,
borderRadius: radiusFull,
},
joined: { alignItems: 'center', gap: space2, backgroundColor: scrim, padding: space4 },
sheet: {
padding: space4,
gap: space3,
backgroundColor: surface,
borderTopWidth: 1,
borderTopColor: border,
},
target: { ...typeLabel, color: textMuted, textAlign: 'center' },
manualHint: { ...typeBodyStrong, color: warn },
cameraIcon: { ...typeLabel, color: text },
});