diff --git a/docs/android.md b/docs/android.md index e8efcf4b2..9ecc66063 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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. diff --git a/docs/release.md b/docs/release.md index 668ba5782..90dd263e3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 4a4e638e1..c8880877e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 959fb27f0..6041247ec 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -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", diff --git a/packages/app/eas.json b/packages/app/eas.json index 42f282fb6..805217b89 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -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": { diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index 59bb6ec7a..e26fa04c3 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/package.json b/packages/app/package.json index 917907fce..b4865ee80 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/plugins/with-fdroid-autolinking.js b/packages/app/plugins/with-fdroid-autolinking.js new file mode 100644 index 000000000..a24d4aa34 --- /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 ca9929cfe..2834fc29f 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -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({ - {isNative ? ( + {isNative && !isFdroidBuild ? ( [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }], diff --git a/packages/app/src/constants/build-profile.ts b/packages/app/src/constants/build-profile.ts new file mode 100644 index 000000000..99f57dd67 --- /dev/null +++ b/packages/app/src/constants/build-profile.ts @@ -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; diff --git a/packages/app/src/fdroid/expo-camera.tsx b/packages/app/src/fdroid/expo-camera.tsx new file mode 100644 index 000000000..e6eac778f --- /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 000000000..dfd4908c2 --- /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; +}