From f4d83cfd8177f274d317a6b1f5857b78c4859a3d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 12 Jul 2026 12:55:36 +0000 Subject: [PATCH] fix(android): complete the F-Droid build profile --- docs/android.md | 6 +- packages/app/app.config.js | 76 +++++++++------ packages/app/metro.config.cjs | 9 ++ .../app/plugins/with-fdroid-autolinking.js | 85 +++++++++++++++++ .../src/components/add-host-method-modal.tsx | 4 +- .../app/src/components/welcome-screen.tsx | 93 ++++++++++--------- packages/app/src/constants/platform.ts | 4 + packages/app/src/fdroid/expo-camera.tsx | 18 ++++ packages/app/src/fdroid/expo-notifications.ts | 40 ++++++++ 9 files changed, 258 insertions(+), 77 deletions(-) create mode 100644 packages/app/plugins/with-fdroid-autolinking.js create mode 100644 packages/app/src/fdroid/expo-camera.tsx create mode 100644 packages/app/src/fdroid/expo-notifications.ts diff --git a/docs/android.md b/docs/android.md index 232d8af292..9ecc660638 100644 --- a/docs/android.md +++ b/docs/android.md @@ -58,10 +58,12 @@ F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild: cd packages/app PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive cd android -./gradlew assembleRelease +PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false ``` -That env var is intentionally narrow. It enables `expo.autolinking.android.buildFromSource: [".*"]` so Expo modules are built from source instead of linked from precompiled AARs. 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. +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 diff --git a/packages/app/app.config.js b/packages/app/app.config.js index cf798d5416..6041247ec3 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,9 +1,52 @@ 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) { @@ -87,6 +130,7 @@ export default { }, updates: { url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a", + ...buildProfile.updates, }, ios: { supportsTablet: true, @@ -110,13 +154,7 @@ 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 } : {}), @@ -127,22 +165,10 @@ export default { }, autolinking: { searchPaths: ["../../node_modules", "./node_modules"], - ...(isFdroidBuild - ? { - android: { - buildFromSource: [".*"], - }, - } - : {}), }, plugins: [ "expo-router", - [ - "expo-camera", - { - cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.", - }, - ], + ...buildProfile.cameraPlugins, [ "expo-splash-screen", { @@ -155,13 +181,7 @@ export default { }, }, ], - [ - "expo-notifications", - { - icon: "./assets/images/notification-icon.png", - color: "#20744A", - }, - ], + ...buildProfile.notificationPlugins, "expo-audio", [ "expo-gradle-jvmargs", @@ -181,6 +201,7 @@ export default { }, }, ], + ...buildProfile.fdroidPlugins, ], experiments: { typedRoutes: true, @@ -188,6 +209,7 @@ export default { autolinkingModuleResolution: true, }, extra: { + fdroidBuild: isFdroidBuild, router: {}, eas: { projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a", diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index 59bb6ec7a9..e26fa04c32 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -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"); diff --git a/packages/app/plugins/with-fdroid-autolinking.js b/packages/app/plugins/with-fdroid-autolinking.js new file mode 100644 index 0000000000..a24d4aa34d --- /dev/null +++ b/packages/app/plugins/with-fdroid-autolinking.js @@ -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; diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index ca9929cfe1..b04d3ae0d7 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -4,7 +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 { isNative } from "@/constants/platform"; +import { isFdroidBuild, isNative } from "@/constants/platform"; const styles = StyleSheet.create((theme) => ({ option: { @@ -86,7 +86,7 @@ export function AddHostMethodModal({ - {isNative ? ( + {isNative && !isFdroidBuild ? ( [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }], diff --git a/packages/app/src/constants/platform.ts b/packages/app/src/constants/platform.ts index f70f78f6c5..3cb19680be 100644 --- a/packages/app/src/constants/platform.ts +++ b/packages/app/src/constants/platform.ts @@ -1,3 +1,4 @@ +import Constants from "expo-constants"; import { Platform } from "react-native"; import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; @@ -28,6 +29,9 @@ export const isNative = Platform.OS !== "web"; /** Development build/runtime — true in Metro dev bundles, false in production. */ export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); +/** F-Droid build without proprietary camera, notification, or OTA dependencies. */ +export const isFdroidBuild = Constants.expoConfig?.extra?.fdroidBuild === true; + // --------------------------------------------------------------------------- // Electron detection (cached — only caches `true`, keeps checking if false // because the desktop bridge may load after initial module evaluation) diff --git a/packages/app/src/fdroid/expo-camera.tsx b/packages/app/src/fdroid/expo-camera.tsx new file mode 100644 index 0000000000..e6eac778fd --- /dev/null +++ b/packages/app/src/fdroid/expo-camera.tsx @@ -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; +} diff --git a/packages/app/src/fdroid/expo-notifications.ts b/packages/app/src/fdroid/expo-notifications.ts new file mode 100644 index 0000000000..dfd4908c2c --- /dev/null +++ b/packages/app/src/fdroid/expo-notifications.ts @@ -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; +}