Skip to content

Development build

Expo Go cannot run this project at all, for two separate reasons: it ships only the newest Expo SDK and this project is on 54, and it cannot link native modules even when the versions line up. Three things the product depends on exist only inside a real build.

Blocked in Expo Go Why it matters
The native engine modules/vlog-editor is a local module, and a local module only exists in a build that compiled it. Without one every edit falls through to the server, which is the path the whole architecture exists to avoid.
Remote push Removed from Expo Go in SDK 53. Push is the trigger for the weekly loop.
Real permissions app.json permissions are not applied inside Expo Go, which runs on its own.

Setup, already done

eas.json, the dev-client plugin and extra.eas.projectId are all committed, so on a fresh checkout there is nothing to configure. If you are starting again from nothing:

cd mobile
npx eas-cli login
npx eas-cli init          # writes extra.eas.projectId into app.json — commit it

That project id is also what makes push work: getExpoPushTokenAsync returns nothing without one.

Before every build

npx expo-doctor

Not optional politeness. The free plan allows 15 Android builds a month, each about nine minutes plus queue time, and a duplicated native dependency or a bad config is caught here in seconds. The native.yml CI job compiles the Kotlin for the same reason — three builds died in a row on compile errors before it existed.

Building

What a build actually does, and where each secret joins it:

flowchart TD
    GIT[Committed files] --> UP[Uploaded to EAS]
    ENVF["EAS file variable<br/>GOOGLE_SERVICES_JSON"] -.->|"gitignored files<br/>arrive this way"| BUILD
    ENVS["EAS secret<br/>SENTRY_AUTH_TOKEN"] -.-> BUILD
    UP --> BUILD[EAS builder]
    BUILD --> PRE[expo prebuild]
    PRE --> GRADLE[Gradle + Kotlin<br/>native module]
    GRADLE --> APK[(APK)]
    BUILD --> MAPS[Source maps] -->|"uploaded during<br/>the build"| SENTRY[(Sentry)]
    APK --> INSTALL([Install on device])

    style APK fill:#2d7d46,color:#fff

The dotted arrows are the part that catches people: anything gitignored is not in the upload, so it has to reach the builder as an environment variable. EAS warns about this and then builds anyway. See gotchas.

# Android, installable APK: about nine minutes of build, plus the free-tier queue
npx eas-cli build --profile development --platform android

# iOS needs an Apple Developer account and a registered device
npx eas-cli device:create
npx eas-cli build --profile development --platform ios

EAS returns a URL; install it on the phone. That build is a shell — it loads the JavaScript from Metro, exactly like Expo Go:

EXPO_PUBLIC_API_URL=http://<your-lan-ip>:8000 npx expo start --dev-client

The API URL is set here rather than in eas.json on purpose: the JS is bundled by Metro at start time, so a native rebuild is not needed to point the app somewhere else.

You only rebuild the shell when the native side changes — a new library, a new permission, a change to the app config. Editing screens or logic does not need one.

Building on this machine instead

EAS is capped at fifteen Android builds a month on the free plan, and August 2026 ran out of them. Locally there is no cap, an incremental build takes about three minutes, and there is no queue.

scripts/build-android.sh preview

The profile name only chooses which env block of eas.json the build gets. Whatever the profile, the script runs gradlew assembleRelease, so it always produces a standalone release APK:

  • development is not a dev client here. Its profile in eas.json has no env, so the result is a release APK with the JavaScript bundled in and error reporting off, pointing at http://localhost:8000 (the fallback in app.json) unless EXPO_PUBLIC_API_URL is already set in your shell — and on a phone, localhost is the phone. For a dev client, use EAS.
  • production is an APK, not the app bundle the Play Store wants. EAS is what builds that.
  • A release build only allows plain HTTP to 10.0.2.2, the emulator's alias for the host (plugins/withEmulatorCleartext.js, there for Maestro). An EXPO_PUBLIC_API_URL=http://192.168.x.x:8000 in a locally built APK fails every request with "Network request failed"; on a real phone, point it at an https:// API.

It publishes the result to a fixed URL when it finishes, so the link to install from never changes:

https://vlogroulette-media.mintos.space/app/latest/vlogroulette.apk

That only works because the object is stored no-store and Cloudflare answers cf-cache-status: BYPASS. A fixed URL is exactly what a CDN likes to cache, and serving a stale APK from a link that looks right is worse than a fresh link each time: you would install it, see none of your changes, and have no reason to suspect the download. SKIP_PUBLISH=1 builds without uploading. Without it the script publishes whatever profile it built, so set it for anything that is not meant to replace the link everybody installs from.

Publishing (scripts/publish-android.py) runs only if backend/.venv exists, since it uses the backend's own storage code, and it reads backend/.env:

  • S3_ENDPOINT_URL, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY for the bucket, and CDN_BASE_URL for the link it prints.
  • CLOUDFLARE_PURGE_TOKEN (or CLOUDFLARE_DNS_TOKEN) to ask Cloudflare to forget the old copy. Optional: without one the upload still happens and the script says the cache was not purged. The zone defaults to the project's own CLOUDFLARE_ZONE_ID, set in build-android.sh.

One-time setup, none of which needs root — the SDK lives in your home directory:

curl -sLO https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip
mkdir -p ~/Android/Sdk/cmdline-tools && unzip -q commandlinetools-linux-*.zip
mv cmdline-tools ~/Android/Sdk/cmdline-tools/latest
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --install \
  "platform-tools" "platforms;android-36" "build-tools;36.0.0"

About 490 MB, plus a JDK 17 — Expo SDK 54 wants 17 specifically. The script does not go looking for one: if JAVA_HOME is unset it uses /usr/lib/jvm/java-17-openjdk, and if JAVA_HOME is already set it is used as it is. Anywhere else, or with JAVA_HOME pointing at a newer JDK, set it to a 17 yourself before running the script.

Why a script rather than a documented gradlew line. A build has to be given the same environment EAS would give it, and those values live in eas.json, which nothing but EAS reads. Building by hand produced an APK pointing at localhost with error reporting switched off: entirely correct-looking, and wrong in a way you would only discover on the phone. The script reads the profile out of eas.json so the two routes cannot drift.

It also needs SENTRY_ORG_TOKEN in backend/.env, or the source map upload fails the build at the very last task — which is exactly what happened the first time. That one is not in .env.example: it belongs to the build, not the API, so add it to your own .env by hand. The script hands it to the Sentry plugin as SENTRY_AUTH_TOKEN, with SENTRY_ORG and SENTRY_PROJECT defaulting to this project's.

Error reporting itself is switched on by EXPO_PUBLIC_SENTRY_DSN, read by app.config.js at build time. preview and production carry it in eas.json; with no DSN the app reports nothing, which is the right default for development.

Signing. An APK signed with a different key cannot be installed over one that is already there; Android refuses. Left alone, the generated project signs release builds with Android's debug key, which is not the project's. The withReleaseSigning config plugin (mobile/plugins/withReleaseSigning.js) switches the release build to the project's own key when the script passes it one, and the script does that whenever backend/.env has ANDROID_KEYSTORE_PATH pointing at a file that exists, together with ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD. The keystore is the one EAS signs with; eas credentials exports it. The script's first lines say which key it is about to use. Without one the build still works and installs on a clean phone, but cannot update an existing install: uninstall first, which costs the signed-in session and nothing else.

Profiles, and which one you want

These are the EAS profiles in eas.json. A local build reads only their env; see above.

Profile What it is
development A dev client shell (debug APK) that loads JavaScript from Metro on your machine and talks to a local API. What you want while working.
preview Standalone APK. Bundles the JS, points at the deployed API, needs nothing running. This is the one to hand somebody.
production Same, as an app bundle for the Play Store, with autoIncrement on the version. Never used yet.

preview and production carry the deployed API URL and the Sentry DSN in eas.json. Both are baked in at build time, so changing either needs a rebuild.

They share a package name, so installing one replaces the other. The data survives the swap, so the new app starts with a session that a different server issued. The client copes: the first 401 makes it try the refresh token, that server refuses it too, and the app drops the session and opens the login screen. Signing in again is all it costs.

Behind the door: the engine

The dev build is what makes the on-device edit run at all, and what it links is modules/vlog-editor — MediaCodec and OpenGL on Android, written directly rather than wrapping FFmpeg. On Android it is done and it runs on real hardware. On iOS it has never been compiled, and needs a paid Apple Developer account before it can be. roadmap.md has how it got here.

Do not add ffmpeg-kit-react-native to package.json hoping to light up the FFmpeg path. It is deprecated and every version of com.arthenica:ffmpeg-kit-* now 404s from Maven Central, so it resolves on npm and then fails at the Gradle step — a slow way to learn what a curl answers in a second. That withdrawal is the reason the custom module exists.