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:
That project id is also what makes push work: getExpoPushTokenAsync returns
nothing without one.
Before every build¶
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:
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.
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:
developmentis not a dev client here. Its profile ineas.jsonhas noenv, so the result is a release APK with the JavaScript bundled in and error reporting off, pointing athttp://localhost:8000(the fallback inapp.json) unlessEXPO_PUBLIC_API_URLis already set in your shell — and on a phone,localhostis the phone. For a dev client, use EAS.productionis 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). AnEXPO_PUBLIC_API_URL=http://192.168.x.x:8000in a locally built APK fails every request with "Network request failed"; on a real phone, point it at anhttps://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_IDandS3_SECRET_ACCESS_KEYfor the bucket, andCDN_BASE_URLfor the link it prints.CLOUDFLARE_PURGE_TOKEN(orCLOUDFLARE_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 ownCLOUDFLARE_ZONE_ID, set inbuild-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.