Skip to content
Fran Gonzalez
← Back to blog
·Clanker·11 min read

Moving a React Native 0.86 app to Expo CNG and fixing its Java 17 release path

How I migrated an Android Expo app from a committed native project to reproducible CNG, then fixed the release task that still inherited the wrong JDK.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

Moving a React Native 0.86 app to Expo CNG and fixing its Java 17 release path

I migrated a React Native project to Expo SDK 57 and React Native 0.86, deleted its committed Android project, and made Expo configuration the native source of truth. The migration passed its runtime suite, but the first release build from main exposed a release-path issue: Firebase App Distribution still inherited Java 25 instead of the app’s pinned Java 17.

The work happened in an initial migration and a follow-up change. The initial migration changed the native ownership model. The follow-up made the release task obey that model and brought the active documentation up to date.

Note

The migration used Expo SDK 57, React Native 0.86, and Temurin Java 17.0.18. The initial migration recorded 620 Jest tests. The follow-up added one regression test for the release wrapper, bringing the final count to 621. All counts are from July 2026.

The Problem

The app started on Expo SDK 55 and React Native 0.83.6 with apps/react-native-project/android/ committed to Git. Native changes accumulated in Gradle files, manifests, drawables, mipmaps, and patch files. Upgrading React Native meant reconciling generated template changes with local edits.

That made three concerns overlap:

  1. Framework upgrades. The Android template moved with Expo and React Native.
  2. Application configuration. Firebase, Google Maps, notifications, network security, versioning, edge-to-edge behavior, and backup policy lived partly in native files.
  3. Build orchestration. The monorepo defaulted to Java 25 for Spring Boot, while Android needed a locked mise Temurin Java 17 toolchain.

Expo’s Continuous Native Generation documentation describes a different ownership model: generate android/ and ios/ on demand from app configuration and dependencies. The generated projects can be deleted and recreated with Expo Prebuild. Durable native changes belong in configuration or config plugins.

The target became:

tracked source                         generated output
──────────────────────────────────     ───────────────────────────────
app.config.ts                      ─┐
plugins/*.cjs                     ──┼─ expo prebuild --platform android
assets/images/*.png               ──┤                │
firebase/google-services.json     ──┘                ▼
                                             android/ (gitignored)

CNG moved native configuration into inputs I could review and regenerate.

What Changed

1. I aligned the framework before deleting native files

Expo SDK 57 maps to React Native 0.86. The dependency upgrade also had to keep the Expo, React Native, Firebase, maps, PowerSync, Reanimated, Worklets, and testing packages compatible.

One failure came from a workspace override that forced @expo/dom-webview 55.0.6 into Expo 57. The JavaScript graph installed, but the native module belonged to another SDK generation. Removing that override let the lockfile resolve 57.0.1. I added a guard that rejects cross-SDK Expo native overrides so the same class of failure cannot return quietly.

I also removed obsolete React Native Gradle patches. A CNG migration that retains patches against the deleted native template still has two sources of truth.

2. I made Expo config the native contract

The app now uses a typed app.config.ts. The following excerpt shows the ownership boundary; helper implementations are omitted:

// apps/react-native-project/app.config.ts
import type { ExpoConfig } from "expo/config";

export default (): ExpoConfig => ({
  name: "Example Mobile App",
  slug: "react-native-project",
  scheme: "reactnativeproject",
  icon: "./assets/images/icon.png",
  android: {
    package: "com.example.reactnative",
    versionCode: resolveAndroidVersionCode(),
    version: resolveAndroidVersionName(),
    adaptiveIcon: {
      foregroundImage: "./assets/images/adaptive-icon.png",
      monochromeImage: "./assets/images/adaptive-icon-monochrome.png",
      backgroundColor: "#FFFFFF",
    },
    googleServicesFile: resolveAndroidGoogleServicesFile(),
    allowBackup: false,
  },
  plugins: [
    "expo-router",
    "expo-secure-store",
    "@react-native-firebase/app",
    "@react-native-firebase/messaging",
    ["expo-notifications", { icon: "./assets/images/notification-icon.png" }],
    ["./plugins/with-android-network-policy.cjs", { apiUrl, powersyncUrl }],
  ],
});

Expo config plugins handled package-owned native configuration. I kept one local plugin for the network policy because it expressed application-specific behavior: HTTPS is allowed, local Android development hosts may use HTTP, and remote HTTP endpoints are rejected.

The policy permits HTTP only for:

  • localhost
  • 127.0.0.1
  • 10.0.2.2, the Android emulator’s host alias

That kept local API and PowerSync development working without enabling cleartext traffic for arbitrary remote hosts.

Two configuration details needed care:

  • React Native Firebase owns token registration, message delivery, and notification tap handling. expo-notifications is present only as a CNG config plugin for the Android notification icon. The app does not use Expo Notifications runtime APIs.
  • I removed the notification color from the Expo plugin configuration because React Native Firebase already contributed one during manifest merging. Keeping both produced a duplicate manifest conflict.

3. I made generated branding reproducible

The canonical images now live under apps/react-native-project/assets/images/:

icon.png
adaptive-icon.png
adaptive-icon-monochrome.png
notification-icon.png

After deleting the native tree, I ran a clean prebuild and compared the generated resources with the former committed files. Expo regenerated the launcher mipmaps, adaptive icon XML, monochrome resources, notification drawables, and splash resources. The adaptive, monochrome, and splash outputs matched the prior native resources.

I used that comparison to verify the tracked inputs before deleting android/app/src/main/res/. The inputs needed to reproduce the generated resources used by the application.

4. I centralized prebuild and APK creation

The app package scripts became the low-level build boundary:

{
  "scripts": {
    "android:prebuild": "pnpm exec expo prebuild --platform android",
    "android:prebuild:clean": "pnpm exec expo prebuild --clean --platform android",
    "android:apk": "pnpm run android:prebuild && cd android && ./gradlew :app:assembleRelease --console=plain"
  }
}

The generated native directories are ignored:

# apps/react-native-project/.gitignore
/android/
/ios/

Local APK builds, QA installation, Firebase distribution, Maestro setup, and Android Studio preparation all route through prebuild instead of assuming a checked-in project.

For routine use, developers call mise tasks rather than Gradle directly:

mise run //apps/react-native-project:prebuild-clean
mise run dev:react-native-project:apk-qa
mise run dev:react-native-project:android-qa
mise run dev:react-native-project:distribute-qa

5. Validation beyond Gradle compilation

A generated project compiling once would not prove the application still worked. The initial migration went through several layers:

  • frozen pnpm install and lockfile/toolchain checks
  • expo install --check
  • Expo Doctor 20/20
  • lint, typecheck, web export, and database-backed contracts
  • 130 Jest suites and 620 tests during the initial migration
  • clean Android prebuilds for local HTTP and remote HTTPS policies
  • release APK compilation and installation on emulator-5554
  • package PID and foreground MainActivity checks before Maestro
  • 20/20 local seeded Maestro flows
  • the default QA Maestro suite
  • authenticated PowerSync download and upload against QA and local stacks
  • SNS/FCM push delivery and chat-push delivery
  • GPS-backed workflow execution, calculation, and export

The initial migration pipeline passed, and the change merged.

Two local PowerSync failures turned out to be machine-specific rather than migration defects. An empty REACT_NATIVE_PROJECT_FEATURE_FLAGS_JSON caused JSON parsing to fail, and Docker Desktop did not share /tmp, so bind-mounted PowerSync YAML files appeared as directories. I used a temporary {} value and moved mirrored config into a shared path under my home directory. Neither workaround belonged in the repository.

The Release Path That Still Used Java 25

After the initial migration merged, I pulled main, removed the migration worktree, and ran:

mise run dev:react-native-project:distribute-qa

Prebuild succeeded. The native build then failed around:

:react-native-worklets:configureCMakeRelWithDebInfo[arm64-v8a]

The first clues were incompatible Gradle daemons. Some used the repository’s Java 25. Another referred to Java 17 inside the deleted worktree. Running the failing CMake task through the app wrapper succeeded with Java 17.

The app already had a wrapper that selected the Java 17 toolchain:

# apps/react-native-project/scripts/run-with-root-env.sh
exec "${repo_root}/bin/mise" x java@temurin-17.0.18+8 -- "$@"

The distribution script bypassed it:

# before
pnpm --filter @react-native-project/mobile run android:apk

That single command inherited the first Java entry in the root mise.toml, Java 25. Development, release-install, and Android Studio tasks used the wrapper, but apk-qa, release-qa, and distribute-qa converged on a shell script that did not.

I temporarily proved the diagnosis by overriding the task environment:

MISE_JAVA_VERSION='temurin-17.0.18+8' \
  mise run dev:react-native-project:distribute-qa

The build completed in 5 minutes 21 seconds, produced a 119 MB APK, and uploaded a QA build to Firebase App Distribution.

That override was evidence, not the fix.

Fixing the Release Boundary

I changed the shared release script so every caller gets the app environment and Java 17:

# apps/react-native-project/scripts/android-qa-release.sh
"${script_dir}/run-with-root-env.sh" \
  pnpm --filter @react-native-project/mobile run android:apk

I wrote the regression test first and watched it fail against the direct pnpm command:

// apps/react-native-project/__tests__/run-with-root-env-test.ts
import { readFileSync } from "node:fs";
import { join } from "node:path";

test("routes QA release builds through the Java 17 wrapper", () => {
  const source = readFileSync(
    join(__dirname, "../scripts/android-qa-release.sh"),
    "utf8",
  );

  expect(source).toMatch(
    /"\$\{script_dir\}\/run-with-root-env\.sh"\s+\\\s+pnpm --filter @react-native-project\/mobile run android:apk/,
  );
});

After the fix, the focused test passed. It was the one additional Jest test in the follow-up, changing the count from 620 during the initial migration to 621. More importantly, the plain command passed without MISE_JAVA_VERSION:

mise run dev:react-native-project:apk-qa
BUILD SUCCESSFUL in 4m 5s
1255 actionable tasks: 1223 executed, 32 up-to-date

APK: .../android/app/build/outputs/apk/release/app-release.apk (119M)

Running the wrapper directly confirmed the selected runtime:

openjdk version "17.0.18"
OpenJDK Runtime Environment Temurin-17.0.18+8

The root tool list intentionally keeps Java 25 first for the backend and Java 17 second for Android. The toolchain consistency check compares the first entry with backend Docker and CI images. Renovate’s mise extractor also tracks only the first array entry, so Java 17 remains a deliberate manual Android pin.

The Documentation Audit

The failed release task showed that code and guidance had drifted together. The follow-up reviewed active React Native project documentation and found several pre-migration claims:

  • Expo Router 55 and React Native 0.83 were still described as current.
  • The CI guide said the Android tree was committed and CNG was future work.
  • The push runbook said not to add expo-notifications, even though its config plugin generated the notification icon.
  • Java documentation claimed the app pinned Java 17 in its own mise.toml; the actual pin was the second root Java entry selected by the wrapper.
  • The CI guide omitted mobile-app:fast and the manual Maestro jobs, used pre-matrix deploy names, and said preprod/prod coverage did not exist.
  • The testing strategy contained a malformed Markdown changelog table.

I updated the active release, local development, mise, testing, push, CI, and agent guidelines. I left old version references under historical-background/ and completed/ intact because those files are point-in-time records, not current operating instructions.

As a mechanical check, I extracted every active documented mise run reference containing react-native-project and ran all 50 through mise run --dry-run. They all resolved.

The follow-up finished with:

  • 130 Jest suites and 621 tests after the follow-up
  • 43 API suites and 244 tests
  • Expo Doctor 20/20
  • clean Expo dependency alignment
  • Renovate policy and schema validation
  • toolchain consistency validation
  • a successful plain QA APK build
  • a green CI pipeline

Results

The Android project is generated from tracked Expo inputs. A clean checkout can recreate it, compile a release APK with the selected JDK, install it, run Maestro, and distribute the artifact to Firebase.

The source-of-truth boundaries are explicit:

ConcernSource of truth
Expo and native package configurationapp.config.ts
Application-specific native mutationplugins/
Launcher and notification brandingassets/images/
Android Firebase application configfirebase/google-services.json
Generated Android projectignored android/ directory
Android JDKlocked Temurin 17 selected by run-with-root-env.sh
Backend JDKfirst root mise Java entry, Temurin 25

The initial migration removed the long-term native maintenance burden. The follow-up closed the release-path exception that would otherwise have required developers to remember an undocumented environment override.

What I’d Do Differently

I would test the public release and distribution task from a clean main checkout before merging the migration, alongside its lower-level APK and installer paths. The lower-level native builds all used Java 17, so the migration looked complete. The user-facing distribution alias crossed a different shell boundary and inherited Java 25.

I would also add the active-document scan to the migration checklist earlier. CNG changed ownership language throughout the repository: “edit AndroidManifest.xml” became “edit app config or a plugin”, and “keep the native resource” became “keep the canonical Expo asset”. Searching only code paths missed operational instructions that could lead someone back to generated files.

The migration established a narrower rule: delete generated native files only after proving their required outputs are reproducible. For this app, that meant comparing icons and splash resources, compiling the exact release APK, installing it, checking the foreground activity, and running sync, push, GPS, and Maestro paths.

References

This post was written with AI assistance.