Get the repo ready for F-Droid
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
This commit is contained in:
2026-10-06 15:26:59 -04:00
co-authored by Claude Opus 5.5
parent 773e6f0e3f
commit a4dad724ad
23 changed files with 522 additions and 14 deletions
+10 -4
View File
@@ -10,11 +10,17 @@ import type { ConfigContext, ExpoConfig } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => {
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? '';
const localHttp = apiUrl.startsWith('http://');
// F-Droid (MUSICSHARE_FOSS=1): no closed-source libraries. The only one is the Google ML Kit /
// Play Services barcode scanner expo-camera bundles; the scan screen decodes QR codes in JS instead.
const foss = process.env.MUSICSHARE_FOSS === '1';
type Plugin = NonNullable<ExpoConfig['plugins']>[number];
const plugins = (config.plugins ?? []).map(
(plugin): Plugin =>
foss && Array.isArray(plugin) && plugin[0] === 'expo-camera' ? ['expo-camera', { ...plugin[1], barcodeScannerEnabled: false }] : plugin,
);
return {
...(config as ExpoConfig),
plugins: [
...(config.plugins ?? []),
['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }],
],
extra: { ...config.extra, foss },
plugins: [...plugins, ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin],
};
};
+2 -1
View File
@@ -22,7 +22,8 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "tech.chrisedwards.musicshare"
"package": "tech.chrisedwards.musicshare",
"versionCode": 1
},
"plugins": [
"expo-router",
+13 -1
View File
@@ -2,6 +2,7 @@
"name": "@musicshare/mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~57.0.21",
@@ -25,6 +26,8 @@
"expo-symbols": "~57.0.3",
"expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"jpeg-js": "^0.4.4",
"jsqr": "^1.4.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
@@ -52,5 +55,14 @@
"typecheck": "tsc --noEmit",
"lint": "expo lint"
},
"private": true
"private": true,
"expo": {
"autolinking": {
"android": {
"buildFromSource": [
".*"
]
}
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
// Signs release builds with the musicshare upload key when it's configured in
// ~/.gradle/gradle.properties (MUSICSHARE_UPLOAD_STORE_FILE, _STORE_PASSWORD,
// _KEY_ALIAS, _KEY_PASSWORD); otherwise falls back to the debug key so builds
// still work on other machines. The key itself never lives in the repo.
// _KEY_ALIAS, _KEY_PASSWORD). Without it the release APK is left unsigned, which
// is what F-Droid expects (it signs with its own key). The key never lives in the repo.
const { withAppBuildGradle } = require('expo/config-plugins');
const MARKER = '// musicshare release signing';
@@ -32,7 +32,7 @@ module.exports = function withReleaseSigning(config) {
if (line === -1) throw new Error('withReleaseSigning: release build type has no signingConfig line');
gradle =
gradle.slice(0, line) +
"signingConfig project.hasProperty('MUSICSHARE_UPLOAD_STORE_FILE') ? signingConfigs.release : signingConfigs.debug" +
"signingConfig project.hasProperty('MUSICSHARE_UPLOAD_STORE_FILE') ? signingConfigs.release : null" +
gradle.slice(line + 'signingConfig signingConfigs.debug'.length);
cfg.modResults.contents = gradle;
+48 -3
View File
@@ -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>
+4
View File
@@ -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;
+8 -2
View File
@@ -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$/, ''));
+25
View File
@@ -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;
}