Support F-Droid Android builds (#1768)

* fix(android): support source-based APK builds

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): address fdroid review feedback

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>

* fix(android): complete the F-Droid build profile

* fix(app): isolate the F-Droid runtime flag

---------

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-07-13 11:18:19 +02:00
committed by GitHub
parent 1f7efbd185
commit b6d49da4df
13 changed files with 328 additions and 67 deletions

View File

@@ -13,6 +13,18 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
`development` uses Android `debug`.
## Version codes
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
```text
major * 1_000_000 + minor * 1_000 + patch
```
Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. Do not re-enable EAS remote version counters or Android `autoIncrement`; F-Droid and other source-based builders need the native build number to be visible in the repo.
The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release.
## Local build + install
From repo root:
@@ -38,6 +50,21 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
```bash
cd packages/app
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.

View File

@@ -188,6 +188,8 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal

11
package-lock.json generated
View File

@@ -19591,6 +19591,16 @@
"react-native": "*"
}
},
"node_modules/expo-gradle-jvmargs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/expo-gradle-jvmargs/-/expo-gradle-jvmargs-1.1.2.tgz",
"integrity": "sha512-VJRecrYlklVXEwafLyuZKUCnYEY9vJYvUy639LN3c5krlmunkYQphh/YzXDng8C9oihz6mr+cPbwEn6sv6fXyw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@expo/config-plugins": "*"
}
},
"node_modules/expo-haptics": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz",
@@ -35250,6 +35260,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",

View File

@@ -1,7 +1,75 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const buildProfile = isFdroidBuild
? {
androidPermissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
],
cameraPlugins: [],
fdroidPlugins: [withFdroidAutolinking],
notificationPlugins: [],
updates: { enabled: false },
}
: {
androidPermissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"CAMERA",
"android.permission.CAMERA",
],
cameraPlugins: [
[
"expo-camera",
{
cameraPermission:
"Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
},
],
],
fdroidPlugins: [],
notificationPlugins: [
[
"expo-notifications",
{
icon: "./assets/images/notification-icon.png",
color: "#20744A",
},
],
],
updates: {},
};
function getNativeBuildVersionCode(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
if (!match) {
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
}
const [, majorText, minorText, patchText] = match;
const major = Number(majorText);
const minor = Number(minorText);
const patch = Number(patchText);
if (minor > 999 || patch > 999) {
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
}
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
}
return versionCode;
}
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
@@ -45,6 +113,7 @@ const variants = {
};
const variant = variants[appVariant] ?? variants.production;
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
export default {
expo: {
@@ -61,6 +130,7 @@ export default {
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...buildProfile.updates,
},
ios: {
supportsTablet: true,
@@ -72,6 +142,7 @@ export default {
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
buildNumber: String(nativeBuildVersionCode),
},
android: {
adaptiveIcon: {
@@ -83,14 +154,9 @@ export default {
softwareKeyboardLayoutMode: "resize",
// Allow HTTP connections for local network hosts (required for release builds)
usesCleartextTraffic: true,
permissions: [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"CAMERA",
"android.permission.CAMERA",
],
permissions: buildProfile.androidPermissions,
package: variant.packageId,
versionCode: nativeBuildVersionCode,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
@@ -102,12 +168,7 @@ export default {
},
plugins: [
"expo-router",
[
"expo-camera",
{
cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
},
],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
{
@@ -120,14 +181,15 @@ export default {
},
},
],
...buildProfile.notificationPlugins,
"expo-audio",
[
"expo-notifications",
"expo-gradle-jvmargs",
{
icon: "./assets/images/notification-icon.png",
color: "#20744A",
xmx: "4096m",
maxMetaspace: "1024m",
},
],
"expo-audio",
[
"expo-build-properties",
{
@@ -139,6 +201,7 @@ export default {
},
},
],
...buildProfile.fdroidPlugins,
],
experiments: {
typedRoutes: true,
@@ -146,6 +209,7 @@ export default {
autolinkingModuleResolution: true,
},
extra: {
fdroidBuild: isFdroidBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",

View File

@@ -1,7 +1,7 @@
{
"cli": {
"version": ">= 14.0.0",
"appVersionSource": "remote"
"appVersionSource": "local"
},
"build": {
"development": {
@@ -19,9 +19,6 @@
"channel": "production",
"env": {
"APP_VARIANT": "production"
},
"android": {
"autoIncrement": "versionCode"
}
},
"production-apk": {

View File

@@ -7,6 +7,11 @@ const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
"expo-notifications": path.resolve(appSrcRoot, "fdroid/expo-notifications.ts"),
};
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.trim()
.replace(/^\./, "")
@@ -72,6 +77,10 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
const origin = context.originModulePath;
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");

View File

@@ -128,6 +128,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",

View File

@@ -0,0 +1,85 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
const EXCLUDED_ANDROID_MODULES = [
"expo-camera",
"expo-notifications",
"expo-dev-client",
"expo-dev-launcher",
"expo-dev-menu",
"expo-dev-menu-interface",
];
function withFdroidAutolinking(config) {
config = withDangerousMod(config, [
"android",
async (modConfig) => {
const packageJsonPath = path.join(modConfig.modRequest.projectRoot, "package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const expo = packageJson.expo ?? {};
const autolinking = expo.autolinking ?? {};
const android = autolinking.android ?? {};
const fdroidPackageJson = {
...packageJson,
expo: {
...expo,
autolinking: {
...autolinking,
android: {
...android,
buildFromSource: [".*"],
exclude: EXCLUDED_ANDROID_MODULES,
},
},
},
};
const overlayRoot = path.join(modConfig.modRequest.platformProjectRoot, "fdroid-autolinking");
await fs.mkdir(overlayRoot, { recursive: true });
await fs.writeFile(
path.join(overlayRoot, "package.json"),
`${JSON.stringify(fdroidPackageJson, null, 2)}\n`,
);
return modConfig;
},
]);
config = withSettingsGradle(config, (modConfig) => {
const fdroidProjectRoot =
'expoAutolinking.projectRoot = new File(rootDir, "fdroid-autolinking")';
if (modConfig.modResults.contents.includes(fdroidProjectRoot)) {
return modConfig;
}
const useExpoModules = "expoAutolinking.useExpoModules()";
if (!modConfig.modResults.contents.includes(useExpoModules)) {
throw new Error("Could not configure F-Droid Expo autolinking in settings.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
useExpoModules,
`${fdroidProjectRoot}\n${useExpoModules}`,
);
return modConfig;
});
return withAppBuildGradle(config, (modConfig) => {
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
return modConfig;
}
const androidBlock = "android {";
if (!modConfig.modResults.contents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
return modConfig;
});
}
module.exports = withFdroidAutolinking;

View File

@@ -4,6 +4,7 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
const styles = StyleSheet.create((theme) => ({
@@ -86,7 +87,7 @@ export function AddHostMethodModal({
</View>
</Pressable>
{isNative ? (
{isNative && !isFdroidBuild ? (
<Pressable
style={styles.option}
onPress={handleScan}

View File

@@ -15,6 +15,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isFdroidBuild } from "@/constants/build-profile";
import { isWeb, isNative } from "@/constants/platform";
interface WelcomeAction {
@@ -201,51 +202,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[onHostAdded, finishOnboarding],
);
const actions: WelcomeAction[] = isWeb
? [
{
key: "direct-connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: handleOpenPasteLink,
},
]
: [
{
key: "scan-qr",
label: t("pairing.connectionMethods.scanQr.title"),
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: handleScanQr,
},
{
key: "direct-connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: handleOpenPasteLink,
},
];
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
? [
{
key: "direct-connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: handleOpenPasteLink,
},
]
: [
{
key: "scan-qr",
label: t("pairing.connectionMethods.scanQr.title"),
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: handleScanQr,
},
{
key: "direct-connection",
label: t("pairing.connectionMethods.direct.title"),
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
label: t("pairing.connectionMethods.pasteLink.title"),
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: handleOpenPasteLink,
},
];
const scrollContentContainerStyle = useMemo(
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],

View File

@@ -0,0 +1,4 @@
import Constants from "expo-constants";
/** F-Droid build without proprietary camera, notification, or OTA dependencies. */
export const isFdroidBuild = Constants.expoConfig?.extra?.fdroidBuild === true;

View File

@@ -0,0 +1,18 @@
const DENIED_CAMERA_PERMISSION = {
canAskAgain: false,
expires: "never",
granted: false,
status: "denied",
} as const;
async function requestCameraPermission() {
return DENIED_CAMERA_PERMISSION;
}
export function useCameraPermissions() {
return [DENIED_CAMERA_PERMISSION, requestCameraPermission] as const;
}
export function CameraView(): null {
return null;
}

View File

@@ -0,0 +1,40 @@
const DENIED_NOTIFICATION_PERMISSION = {
canAskAgain: false,
expires: "never",
granted: false,
status: "denied",
} as const;
export const PermissionStatus = {
DENIED: "denied",
GRANTED: "granted",
UNDETERMINED: "undetermined",
} as const;
export const AndroidImportance = { DEFAULT: 3 } as const;
export async function getPermissionsAsync() {
return DENIED_NOTIFICATION_PERMISSION;
}
export async function requestPermissionsAsync() {
return DENIED_NOTIFICATION_PERMISSION;
}
export async function getExpoPushTokenAsync() {
return { data: "" };
}
export async function setNotificationChannelAsync() {
return null;
}
export function setNotificationHandler(): void {}
export function addNotificationResponseReceivedListener() {
return { remove() {} };
}
export async function getLastNotificationResponseAsync() {
return null;
}