CI / check (push) Canceled after 0s
- License: AGPL-3.0-or-later (LICENSE, every package.json, README). - MUSICSHARE_FOSS=1 builds leave out the only closed-source code, Google's ML Kit / Play Services barcode scanner bundled by expo-camera; the scan screen then decodes small camera snapshots with jsQR instead. - Expo modules build from source rather than from precompiled AARs, as F-Droid requires (that's also what makes the scanner switch take effect). Verified: a FOSS build contains no ML Kit or Play Services classes. - Release builds default to the production server, and come out unsigned without our upload key (F-Droid signs with its own). versionCode 1. - Store listing in fastlane/metadata/android, a draft fdroiddata recipe in fdroid/, and docs/FDROID.md with the submission steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||
import { router } from 'expo-router';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { Linking, StyleSheet, Text, View } from 'react-native';
|
||
import { parseProfileLink } from '@musicshare/shared';
|
||
import { Screen } from '@/components/Screen';
|
||
import { Body, Button, Title } from '@/components/ui';
|
||
import { FOSS_BUILD } from '@/lib/build';
|
||
import { decodeQrFromJpeg, pickSnapshotSize } from '@/lib/qrSnapshot';
|
||
import { useSession } from '@/lib/session';
|
||
import { colors, radius, space } from '@/lib/theme';
|
||
|
||
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
|
||
export default function ScanScreen() {
|
||
const { me } = useSession();
|
||
const [permission, requestPermission] = useCameraPermissions();
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
// The camera reports the same code many times a second; act on the first.
|
||
const handled = useRef(false);
|
||
const camera = useRef<CameraView>(null);
|
||
const [snapshotSize, setSnapshotSize] = useState<string | undefined>();
|
||
|
||
const onCode = (data: string) => {
|
||
if (handled.current) return;
|
||
const username = parseProfileLink(data);
|
||
if (!username) {
|
||
setMessage('That’s not a musicshare code.');
|
||
return;
|
||
}
|
||
if (username === me?.username) {
|
||
setMessage('That’s your own code. Show it to a friend instead!');
|
||
return;
|
||
}
|
||
handled.current = true;
|
||
router.replace(`/u/${username}`);
|
||
};
|
||
|
||
// The snapshot loop calls whatever the current handler is.
|
||
const latest = useRef(onCode);
|
||
useEffect(() => {
|
||
latest.current = onCode;
|
||
});
|
||
const granted = Boolean(permission?.granted);
|
||
|
||
// F-Droid builds have no ML Kit: take a small snapshot every moment and decode it in JS.
|
||
useEffect(() => {
|
||
if (!FOSS_BUILD || !granted) return;
|
||
let stopped = false;
|
||
let timer: ReturnType<typeof setTimeout>;
|
||
const tick = async () => {
|
||
if (stopped || handled.current) return;
|
||
try {
|
||
const photo = await camera.current?.takePictureAsync({ base64: true, quality: 0.5, skipProcessing: true, shutterSound: false });
|
||
const data = photo?.base64 ? decodeQrFromJpeg(photo.base64) : null;
|
||
if (data) latest.current(data);
|
||
} catch {
|
||
// camera not ready yet, or a blurry frame; try again
|
||
}
|
||
if (!stopped) timer = setTimeout(tick, 600);
|
||
};
|
||
timer = setTimeout(tick, 800);
|
||
return () => {
|
||
stopped = true;
|
||
clearTimeout(timer);
|
||
};
|
||
}, [granted]);
|
||
|
||
if (!permission) return <Screen />;
|
||
|
||
if (!permission.granted) {
|
||
return (
|
||
<Screen edges={['bottom']} contentContainerStyle={styles.centered}>
|
||
<Title>Scan a friend’s code</Title>
|
||
<Body muted style={{ textAlign: 'center' }}>
|
||
musicshare needs the camera to scan QR codes. It’s only used while this screen is open.
|
||
</Body>
|
||
{permission.canAskAgain ? (
|
||
<Button variant="primary" title="Allow camera" onPress={requestPermission} />
|
||
) : (
|
||
<Button variant="primary" title="Open Settings" onPress={() => Linking.openSettings()} />
|
||
)}
|
||
<Button title="Show my code instead" onPress={() => router.replace('/qr')} />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={styles.fill}>
|
||
{FOSS_BUILD ? (
|
||
<CameraView
|
||
ref={camera}
|
||
style={styles.fill}
|
||
facing="back"
|
||
pictureSize={snapshotSize}
|
||
animateShutter={false}
|
||
onCameraReady={async () => setSnapshotSize(pickSnapshotSize((await camera.current?.getAvailablePictureSizesAsync()) ?? []))}
|
||
/>
|
||
) : (
|
||
<CameraView style={styles.fill} facing="back" barcodeScannerSettings={{ barcodeTypes: ['qr'] }} onBarcodeScanned={({ data }: BarcodeScanningResult) => onCode(data)} />
|
||
)}
|
||
<View style={styles.overlay} pointerEvents="box-none">
|
||
<View style={styles.frame} />
|
||
<Text style={styles.prompt}>{message ?? 'Point at a friend’s musicshare code'}</Text>
|
||
<Button title="Show my code" onPress={() => router.replace('/qr')} />
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
fill: { flex: 1, backgroundColor: '#000' },
|
||
centered: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
|
||
overlay: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center', gap: space.xl, padding: space.xl },
|
||
frame: { width: 240, height: 240, borderRadius: radius.lg, borderWidth: 3, borderColor: colors.accentText },
|
||
prompt: {
|
||
color: colors.accentText,
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
textAlign: 'center',
|
||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||
paddingHorizontal: space.md,
|
||
paddingVertical: space.sm,
|
||
borderRadius: radius.md,
|
||
overflow: 'hidden',
|
||
},
|
||
});
|