fix(android): support source-based APK builds

Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-07-12 11:55:00 +00:00
co-authored by Anton Lazarev
parent c05e337cde
commit 7b7bcc1032
6 changed files with 87 additions and 4 deletions
+25
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
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.
+2
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 `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
+11
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",
+47
View File
@@ -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,
+1 -4
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": {
+1
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",