diff --git a/docs/android.md b/docs/android.md index e8efcf4b23..99a0f2ee62 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 +1_000_000 + major * 1_000_000 + minor * 1_000 + patch +``` + +Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1001102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. The `1_000_000` floor keeps the local formula above prior EAS remote counters during the transition. 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,19 @@ 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 +./gradlew assembleRelease +``` + +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, and it applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` for the larger source build. Normal local and EAS builds leave those settings off. + ### 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 668ba5782e..bd572bb6a3 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 `1_000_000 + 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 86163beb9f..8763f5dd44 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 959fb27f01..7409ee2883 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -2,6 +2,32 @@ const fs = require("node:fs"); const path = require("node:path"); const pkg = require("./package.json"); const appVariant = process.env.APP_VARIANT ?? "production"; +const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; +const nativeBuildVersionFloor = 1_000_000; + +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 = nativeBuildVersionFloor + 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 +71,7 @@ const variants = { }; const variant = variants[appVariant] ?? variants.production; +const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version); export default { expo: { @@ -72,6 +99,7 @@ export default { ...(variant.googleServiceInfoPlist ? { googleServicesFile: variant.googleServiceInfoPlist } : {}), + buildNumber: String(nativeBuildVersionCode), }, android: { adaptiveIcon: { @@ -91,6 +119,7 @@ export default { "android.permission.CAMERA", ], package: variant.packageId, + versionCode: nativeBuildVersionCode, ...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}), }, web: { @@ -99,6 +128,13 @@ export default { }, autolinking: { searchPaths: ["../../node_modules", "./node_modules"], + ...(isFdroidBuild + ? { + android: { + buildFromSource: [".*"], + }, + } + : {}), }, plugins: [ "expo-router", @@ -139,6 +175,17 @@ export default { }, }, ], + ...(isFdroidBuild + ? [ + [ + "expo-gradle-jvmargs", + { + xmx: "4096m", + maxMetaspace: "1024m", + }, + ], + ] + : []), ], experiments: { typedRoutes: true, diff --git a/packages/app/eas.json b/packages/app/eas.json index 42f282fb63..805217b895 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/package.json b/packages/app/package.json index 0e0ef7a478..18072f824e 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",