Files
musicshare/apps/mobile/src/app/scan.tsx
T
chrisandClaude Opus 5.5 a4dad724ad
CI / check (push) Canceled after 0s
Get the repo ready for F-Droid
- 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
2026-10-06 15:26:59 -04:00

127 lines
4.8 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 { 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',
},
});