---
name: gradle-android-builds
description: Reference for building Expo/React Native Android APKs locally via EAS CLI. Covers Gradle memory tuning, init scripts, SDK constraints, duplicate resource fixes, disk management, AGP buildscript conflicts, expo-dev-menu patches, and common failure patterns. Use when building or debugging Android/Gradle builds.
---

# Gradle Android Builds — Treehouse Reference

Accumulated knowledge from building Expo SDK 54 / React Native 0.81.x APKs locally on the Treehouse platform.

---

## 1. Platform Android SDK (read-only mount)

The host exposes the Android SDK at `/opt/android-sdk` — real files, mountable by apps with tenant permission:

```yaml
volumes:
  - /opt/android-sdk:/opt/android-sdk:ro
```

Installed components (as of 2026-03):
- NDK `26.1.10909125` (React Native 0.73 / Expo SDK 51-53)
- NDK `27.1.12297006` (React Native 0.79+ / Expo SDK 54 new arch — includes C++23 std::format)
- Platforms: android-34, android-35 (NOT 36)
- Build-tools: 34.0.0, 35.0.0
- platform-tools, cmdline-tools/latest
- cmake and ninja (available via system PATH in the builder image)

**Note**: Expo SDK 54 / AGP 8.7 defaults to NDK 27.1.12297006 (now installed) and android-36 (NOT installed — must still patch compileSdk/targetSdk to 35).

---

## 2. gradle.properties (Gradle daemon JVM)

Mount `./gradle-cache:/root/.gradle` in app.yml, then write `gradle-cache/gradle.properties`:

```properties
# Gradle daemon JVM — controls ONLY the daemon process
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
org.gradle.workers.max=1
org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.caching=true
```

**Why 2048m**: The `mergeDebugJavaResource` task merges Java resources from all libraries into one JAR. With Expo's 15+ native modules this can allocate >1GB. At 512m the daemon silently OOM-crashes and hangs — no error is logged, `buildRunning: true` stays forever.

**Symptom of daemon OOM**: Log ends with `> Task :app:mergeDebugJavaResource FAILED`, no error details, process never exits, `buildRunning` stays true for hours.

**Fix**: 2048m daemon heap + packagingOptions (see §5).

---

## 3. server.js environment variables

```javascript
const env = {
  ...process.env,
  ANDROID_HOME: '/opt/android-sdk',
  ANDROID_NDK_HOME: '/opt/android-sdk/ndk/27.1.12297006',  # or 26.1.10909125 for older SDK
  JAVA_HOME: '/usr/lib/jvm/java-17-openjdk-amd64',
  HOME: '/root',
  EAS_NO_VCS: '1',                     // skip git check
  GRADLE_USER_HOME: '/root/.gradle',
  GRADLE_OPTS: '-Dfile.encoding=UTF-8 -Dorg.gradle.user.home=/root/.gradle',
  JAVA_TOOL_OPTIONS: '-Dfile.encoding=UTF-8',  // MINIMAL — applies to ALL JVMs
};
```

**JAVA_TOOL_OPTIONS pitfall**: This applies to EVERY JVM that starts — the Gradle wrapper, the daemon, the Kotlin daemon. If you set `-Xmx512m` here, every JVM gets that cap and total memory use multiplies. Keep it to encoding only. Set heap in `gradle.properties` for daemon-only scope.

---

## 4. Gradle init script (NDK / SDK version patching)

Location: `gradle-cache/init.d/ndk-fix.gradle` → maps to `/root/.gradle/init.d/ndk-fix.gradle`.

This runs BEFORE every Gradle invocation and patches AGP defaults:

```groovy
allprojects {
    afterEvaluate { project ->
        // NDK version: 27.1 for SDK 54 new-arch (std::format), 26.1 for SDK 51-53
        try {
            if (project.android) {
                project.android.ndkVersion = project.android.ndkVersion ?: '27.1.12297006'
                project.android.compileSdkVersion = 35
                project.android.buildToolsVersion = '35.0.0'
            }
        } catch (ignored) {}

        // Set NDK path on root project (expo-modules-core reads rootProject.ext)
        rootProject.ext.ndkVersion = '27.1.12297006'
        rootProject.ext.ndkPath = '/opt/android-sdk/ndk/27.1.12297006'

        // Write cmake.dir to local.properties (AGP reads this for CMake location)
        def localProps = new File(rootProject.projectDir, 'local.properties')
        def cmakeDir = '/usr'
        if (!localProps.exists() || !localProps.text.contains('cmake.dir')) {
            localProps.append("\ncmake.dir=${cmakeDir}")
        }

        // ABI filter — arm64 only, halves native compile time
        try {
            android.defaultConfig.ndk.abiFilters = ['arm64-v8a']
        } catch (ignored) {}

        // Limit CMake parallelism
        try {
            android.defaultConfig.externalNativeBuild.cmake.arguments '-DCMAKE_BUILD_PARALLEL_LEVEL=2'
        } catch (ignored) {}

        // Fix duplicate Java resource conflicts (common in Expo/RN builds)
        try {
            android.packagingOptions {
                pickFirst 'META-INF/LICENSE'
                pickFirst 'META-INF/LICENSE.txt'
                pickFirst 'META-INF/NOTICE'
                pickFirst 'META-INF/NOTICE.txt'
                pickFirst 'META-INF/MANIFEST.MF'
                pickFirst 'META-INF/*.kotlin_module'
                pickFirst 'kotlin/**'
                pickFirst 'androidsupportmultidexversion.txt'
                exclude 'META-INF/DEPENDENCIES'
                exclude 'META-INF/AL2.0'
                exclude 'META-INF/LGPL2.1'
                exclude 'DebugProbesKt.bin'
            }
        } catch (ignored) {}
    }
}
```

**afterEvaluate is required**: `beforeProject` fires before the module's `android {}` block — the module overrides your values. `afterEvaluate` fires after, so your values stick.

---

## 5. Duplicate Java resources (mergeDebugJavaResource)

Expo SDK 54 with 15+ native modules produces duplicate META-INF files across library JARs. The `mergeDebugJavaResource` task fails or hangs when it finds them.

Fix via `packagingOptions` in the init script (see §4 above).

**How to diagnose**: If the build hangs at `mergeDebugJavaResource FAILED` with no error details and never exits, it is almost always the daemon OOM (§2), not a real resource conflict. Increase heap first.

If there IS a real conflict (build exits fast with error), search the log for `Duplicate files` or `More than one file was found` to identify the specific path, then add a `pickFirst` for it.

---

## 6. expo-dev-client versioning

| Expo SDK | expo-dev-client version |
|----------|------------------------|
| 53       | ~5.0.x                 |
| 54       | ~6.0.20                |

**Version mismatch errors (SDK 53 package with SDK 54 build):**
- `unresolved reference: JSCInstance`
- `unresolved reference: JSEngineResolutionAlgorithm`
- `unresolved reference: jSBundleURLForRemoteDebugging`
- `unresolved reference: isRemoteJSDebugEnabled`

These appear in `DevMenuReactHost.kt` and `DevMenuDevSettings.kt`. Root cause: expo-dev-client ~5.x ships RN 0.73 APIs, SDK 54 / RN 0.81 has removed them.

**Lockfile sync**: After updating any package version in package.json, use `npm install` (not `npm ci`) until lockfile is re-synced. `npm ci` is strict — it errors if package.json and lockfile don't match exactly. Switch Dockerfile from `npm ci` to `npm install` after version bumps, then run once to regenerate the lockfile, then switch back if desired.

---

## 7. postinstall patches

Used when node_modules contain hardcoded incompatible versions. EAS runs postinstall after `npm ci`, before Gradle.

### NDK version patch
```json
{
  "scripts": {
    "postinstall": "find node_modules -name 'build.gradle' -exec sed -i \'s/ndkVersion \"26\.1\.10909125\"/ndkVersion \"27.1.12297006\"/g\' {} +"
  }
}
```

### Third-party AGP buildscript conflict (RN modules from 2021-2023 era)

Native modules like `@picovoice/porcupine-react-native` include their own `buildscript { classpath 'com.android.tools.build:gradle:7.4.2' }` block. In Gradle 8.x, this clashes with the root project's AGP 8.x classpath and causes task creation failures in other subprojects.

**Fix**: postinstall removes the offending `buildscript` block from the package's `build.gradle`:

```json
{
  "scripts": {
    "postinstall": "node -e \"const fs=require(\'fs\'); const p=\'node_modules/@picovoice/porcupine-react-native/android/build.gradle\'; if(fs.existsSync(p)){let c=fs.readFileSync(p,\'utf8\');c=c.replace(/buildscript\\s*\\{[^}]*classpath[^}]*\\}/s, \'\');fs.writeFileSync(p,c);}\"" 
  }
}
```

**Pattern**: any RN native module from 2021-2023 that bundles its own AGP classpath declaration may have this issue. Symptom: `Could not create task ':some-other-module:someTask'` after the offending module is evaluated.

### expo-dev-menu packageDebugAssets lazy task failure

`expo-dev-menu/android/build.gradle` line 76 accesses `packageDebugAssets` as a direct Groovy property in `afterEvaluate`. In AGP 8.x, tasks may not be registered at that evaluation point when included via `expo-dev-launcher-gradle-plugin`.

**Symptom**: `Could not get unknown property 'packageDebugAssets' for project ':expo-dev-menu'`

**Fix** (postinstall patch to `node_modules/expo-dev-menu/android/build.gradle`):

Replace:
```groovy
packageDebugAssets.dependsOn(copyAssets)
```
With:
```groovy
try { tasks.named('packageDebugAssets').configure { dependsOn copyAssets } } catch (ignored) {}
```

**Note**: Fixing the AGP buildscript conflict (above) may also resolve this — evaluate whether the patch is needed after removing conflicting buildscript blocks.

---

## 8. Disk management

Gradle cache accumulates fast. Breakdown of `gradle-cache/` directory:
- `caches/` — Maven artifacts, 2–3GB, re-downloadable, safe to delete
- `wrapper/` — Gradle distribution, ~150MB, re-downloadable, safe to delete
- `init.d/` — init scripts, KEEP
- `gradle.properties` — KEEP

After deleting `caches/` and `wrapper/`, the next build re-downloads everything. With `org.gradle.caching=true`, subsequent builds use the build cache.

To check real disk usage (includes containerd/buildkit, invisible to df):
```
tc runtime disk
```

To free build cache:
```
tc runtime prune builder
```

To free old images (after app-remove):
```
tc runtime prune images-all
```

---

## 9. Container / runtime failure patterns

| Symptom | Cause | Fix |
|---------|-------|-----|
| Build hangs at `mergeDebugJavaResource FAILED`, no error, never exits | Daemon OOM | Increase `org.gradle.jvmargs` to -Xmx2048m |
| `EOVERFLOW` / `value too large for defined data type` on container start | containerd snapshot store corrupted | `tc runtime reset-buildkit` |
| `no space left on device` during image export | Disk full | `tc runtime prune builder` + `tc runtime prune images-all` |
| `NDK was not found` | AGP looking for NDK not installed | Add `rootProject.ext.ndkVersion` in init script pointing to installed version |
| `platforms;android-36 not installed` | AGP defaulting to SDK 36 | Cap `compileSdkVersion=35` in init script via afterEvaluate |
| `npm ci` fails — lockfile mismatch | package.json and lockfile out of sync | Run `npm install` in container, copy lockfile back |
| Build exits 0 but APK at wrong path | EAS stores artifacts in `/build/build-*.apk` | Symlink or copy to server's `/apk` path |
| `Could not create task ':some-module:task'` after evaluating a third-party module | Third-party AGP buildscript classpath conflict (2021-2023 era RN modules) | postinstall: remove the offending `buildscript {}` block from the package's build.gradle |
| `Could not get unknown property 'packageDebugAssets' for project ':expo-dev-menu'` | expo-dev-menu lazy task failure in AGP 8.x | postinstall: replace direct property access with `tasks.named(...).configure {}` — see §7 |
| `unresolved reference: JSCInstance` / `JSEngineResolutionAlgorithm` in DevMenuReactHost.kt | expo-dev-client SDK 53 (~5.x) used with SDK 54 build | Update to expo-dev-client ~6.0.20 — see §6 |
| Builder HTTP endpoint times out mid-build during C/C++ compilation | Expected — Node event loop starved during native compilation (Porcupine, expo-modules-core JSI). Not a bug. | Wait it out; build will complete. Add polling/retry to health-check endpoint. |

---

## 10. EAS local build flow

```
eas build --local --profile development --platform android
```

Steps EAS runs:
1. `npm ci` (requires lockfile sync)
2. postinstall scripts
3. `expo prebuild` (generates android/ directory)
4. `gradlew :app:assembleDebug` (development) or `assembleRelease` (preview/production)
5. Copies APK to artifacts directory

Development profile: produces APK with Expo Dev Launcher, connects to Metro server.
Preview profile: produces self-contained APK, no dev server needed.

---

## 11. Build times (with warm cache)

| Phase | Duration |
|-------|----------|
| npm ci + postinstall | ~15s |
| expo prebuild | ~10s |
| Gradle configuration | ~20s |
| Kotlin compilation (all modules) | ~45s (from cache) |
| mergeDebugJavaResource | ~10s |
| packageDebug + APK signing | ~10s |
| **Total (warm cache)** | **~1m 50s** |

Cold build (no cache): 20–40 minutes.