- 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
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { router } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
@@ -15,8 +17,10 @@ export default function ScanScreen() {
|
||||
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 onScanned = ({ data }: BarcodeScanningResult) => {
|
||||
const onCode = (data: string) => {
|
||||
if (handled.current) return;
|
||||
const username = parseProfileLink(data);
|
||||
if (!username) {
|
||||
@@ -31,6 +35,36 @@ export default function ScanScreen() {
|
||||
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) {
|
||||
@@ -52,7 +86,18 @@ export default function ScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.fill}>
|
||||
<CameraView style={styles.fill} facing="back" barcodeScannerSettings={{ barcodeTypes: ['qr'] }} onBarcodeScanned={onScanned} />
|
||||
{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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
/** F-Droid build: no closed-source libraries (QR codes are decoded in JS). */
|
||||
export const FOSS_BUILD = Constants.expoConfig?.extra?.foss === true;
|
||||
@@ -7,7 +7,13 @@ import Constants from 'expo-constants';
|
||||
*/
|
||||
const devHost = Constants.expoConfig?.hostUri?.split(':')[0];
|
||||
|
||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000');
|
||||
/** The hosted server. Release builds use it unless EXPO_PUBLIC_API_URL says otherwise (e.g. F-Droid builds set nothing). */
|
||||
const PRODUCTION_API = 'https://musicshare.binarygnome.com/api';
|
||||
|
||||
export const API_URL =
|
||||
process.env.EXPO_PUBLIC_API_URL ?? (__DEV__ ? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000') : PRODUCTION_API);
|
||||
|
||||
/** The website, for shareable profile links and QR codes. In development, the web app on port 3737. */
|
||||
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? (devHost ? `http://${devHost}:3737` : API_URL.replace(/:4000$/, ':3737'));
|
||||
export const WEB_URL =
|
||||
process.env.EXPO_PUBLIC_WEB_URL ??
|
||||
(__DEV__ ? (devHost ? `http://${devHost}:3737` : API_URL.replace(/:4000$/, ':3737')) : API_URL.replace(/\/api$/, ''));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import jpeg from 'jpeg-js';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
/**
|
||||
* Reads a QR code from a small JPEG snapshot (base64), entirely in JS. Used by
|
||||
* F-Droid builds, which leave out Google's ML Kit scanner. Returns the code's
|
||||
* text, or null if there isn't one.
|
||||
*/
|
||||
export function decodeQrFromJpeg(base64: string): string | null {
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
const image = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true, maxResolutionInMP: 4 });
|
||||
const code = jsQR(new Uint8ClampedArray(image.data.buffer, image.data.byteOffset, image.data.length), image.width, image.height, {
|
||||
inversionAttempts: 'dontInvert',
|
||||
});
|
||||
return code?.data ?? null;
|
||||
}
|
||||
|
||||
/** The smallest snapshot size that still reads a QR code held at arm's length. */
|
||||
export function pickSnapshotSize(sizes: string[]): string | undefined {
|
||||
const parsed = sizes
|
||||
.map((s) => ({ s, w: Number(s.split('x')[0]), h: Number(s.split('x')[1]) }))
|
||||
.filter((p) => p.w > 0 && p.h > 0)
|
||||
.sort((a, b) => a.w * a.h - b.w * b.h);
|
||||
return (parsed.find((p) => Math.min(p.w, p.h) >= 480) ?? parsed.at(-1))?.s;
|
||||
}
|
||||
Reference in New Issue
Block a user