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.
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
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 },
|
||
});
|