Building for Android
This page assumes you have never built an Android app. It goes in order: install the tools, check they work, configure your app, run it on a simulated phone, then run it on your actual phone.
If you already know Android, skip to Reference at the bottom.
What you are building
Your app stays where it is, deployed on your server, doing what it already does. What we build is a shell: a small native Android app whose whole job is to display your site in a full-screen web view with no browser chrome, plus a native tab bar, native navigation, and the bridge (notifications, haptics, biometrics, camera).
That means:
- Your Ruby is never compiled into the app. No Tebako here. The app is a wrapper; the server does the work.
remote.urlis required. The app needs a deployed URL to point at. If your app is not deployed anywhere yet, deploy it first, even to a free tier.- Most changes need no new app build. Change a page, deploy, done. Only the things stamped into the app (its name, icon, colors, tabs, permissions) need a rebuild.
The words you will see
Android has a lot of vocabulary and most of it is unavoidable. Here is the whole set for this page, in plain terms:
| Word | What it actually is |
|---|---|
| SDK | The pile of Android tools and libraries on your machine. One folder, around 10 GB. |
| API level | Android's version number for developers. API 36 = Android 16. Your app is built against API 36 and runs on Android 9 and up. |
| JDK | Java. Android's build system runs on it. You need version 17 or newer. |
| Gradle | Android's build tool, like rake or npm run build. You never invoke it; the CLI does. |
| adb | Android Debug Bridge. The command-line tool your computer uses to talk to a phone or emulator: install apps, read logs, forward ports. |
| Emulator / AVD | A simulated Android phone running on your Mac. An AVD is one configured device, like "Pixel 8, API 36". |
| APK | The installable app file. Like a .dmg, but for Android. |
| AAB | A bundle you upload to Google Play. It cannot be installed on a phone. Play turns it into APKs. |
| applicationId | Your app's permanent unique id, like com.example.notes. |
| Debug build | A build for testing. Signed automatically with a throwaway key. Installs on your phone with no setup. |
| Release build | A build for shipping. You must sign it yourself or it will not install anywhere. |
| Keystore | The file holding your signing key. Lose it and you can never update your app on Play again. |
| logcat | Android's log stream. |
Step 1: Install Android Studio
Download and install Android Studio.
You will almost never open it. Installing it is still the easiest way to get the
SDK, adb, the emulator, the build tools and a JDK, in matching versions, in
one download. Every other route is a scavenger hunt.
Run it once after installing so its setup wizard downloads the SDK. Accept the defaults.
You do not need to edit
.zshrcor add anything to yourPATH. Android Studio installs all these tools and exports none of them, so on a perfectly working machinewhich adbprints nothing. That is normal. The CLI finds each tool by its known location under the SDK folder.
Step 2: Tick two boxes in the SDK Manager
Android Studio โ Settings โ Languages & Frameworks โ Android SDK.
Under the SDK Platforms tab, check Android 16 (API 36). This is what the shell compiles against. Without it the build fails several minutes in, with an unhelpful message.
Under the SDK Tools tab, check:
- Android SDK Build-Tools
- Android SDK Platform-Tools, which is what gives you
adb - Android Emulator, only if you want a simulated phone
- Android SDK Command-line Tools (latest), optional, but it is what lets the CLI print install commands you can paste when something else is missing
Click Apply and let it download.
Step 3: Check your work
cd my_app
every doctor --target android
โ Android target
โ Android SDK (~/Library/Android/sdk)
โ JDK 17+ (found 21)
โ Android SDK command-line tools (~/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager)
โ adb (~/Library/Android/sdk/platform-tools/adb)
โ emulator (~/Library/Android/sdk/emulator/emulator)
โ an Android virtual device (Medium_Phone_API_36)
โ platforms;android-36 (have android-35, android-36)
โ bundled Android shell template
Every โ comes with the exact command, or the exact Studio menu path, that
fixes it. Get this green before going further: an error here takes thirty
seconds to fix, and the same problem found mid-build is a wall of Gradle output.
If it cannot find the SDK or the JDK, and you know they are installed somewhere unusual, point at them:
export ANDROID_HOME=/path/to/sdk
export JAVA_HOME=/path/to/jdk-17
Those are the only two environment variables involved, and most people need neither.
If you want an emulator and have none, make one in Android Studio โ Device Manager โ Add a new device. Pick any phone, pick API 36, done.
Step 4: Configure your app
Two things are required. Add them to config/everywhere.yml:
app:
name: Notes
remote:
url: "https://notes.example.com" # where your app is deployed
platforms:
android:
bundle_id: com.example.notes # your app's permanent Android id
The bundle_id trap
This one catches almost everyone, so it is worth a moment.
On Android this value is the applicationId, and its rules are stricter than Apple's. It must be two or more dot-separated chunks, each chunk made only of letters, digits and underscores, and each chunk starting with a letter.
In particular: no hyphens.
That matters because if you leave it out, one is derived from your app's name,
and an app called "My Notes" derives com.rubyeverywhere.my-notes, which is
exactly the shape Android rejects. So any app whose name is not a single plain
word must set this.
# โ
com.example.notes
# โ
com.mycompany.mybookkeeper
# โ com.example.my-notes hyphen
# โ com.example.2notes chunk starts with a digit
# โ notes only one chunk
Pick it carefully. This is your app's permanent identity on the device and in the Play Store. Changing it later means users install a second, separate app rather than getting an update.
The build checks this before Gradle starts, so a bad value costs you seconds, not minutes.
Everything else is optional
app:
name: Notes
version: "1.2.3"
icon: icon.png # or just drop icon.png at your app root
appearance:
tint_color: "#DC143C"
background_color:
light: "#FFFFFF"
dark: "#101010"
tabs:
- name: Home
path: /
icons:
android: home
- name: Builds
path: /builds
icons:
android: build
Colors drive the theme, the splash screen and the system chrome, in light and dark. The icon becomes launcher icons at every screen density.
Tab icon names come from
fonts.google.com/icons: the name written under
each icon, lowercased with underscores, so home, build_circle,
account_circle. Every name is checked at build time against the font that
actually ships, so a typo fails the build and names the tab it came from instead
of shipping you a blank square.
The first Android build downloads the Material Symbols icon font, about 10 MB,
cached forever and shared by all your apps. If you are offline or would rather
not, native.android.icon_font: classic uses a smaller set bundled in the gem.
Android's tab bar holds five slots. Declare more than five tabs and the last slot becomes "More", with the rest opening from a sheet behind it. Nothing is lost; the CLI warns you what the layout will be.
Step 5: Run it on the emulator
Try this before touching hardware. If something is wrong with your config, you would rather find out here.
every dev --android
That one command starts your dev server, builds the Android app, boots the emulator, installs the app, launches it, and points it at your local dev server. The first run takes a few minutes while it downloads Gradle and Android's build plugin, about 1.4 GB, shared across every app you ever build. After that it is seconds.
While it runs:
- press
ato rebuild and reinstall the Android app - press
lto toggle the log stream - Ctrl-C stops everything
If the app loads your site, your config is fine. Now for the real phone.
Step 6: Get it on your phone
6a. Turn your phone into a development device
Android hides this behind an easter egg. On the phone:
- Settings โ About phone
- Find Build number and tap it seven times. It counts down at you, then says "You are now a developer."
- Go back to Settings โ System โ Developer options, a new menu that just appeared
- Turn on USB debugging
6b. Plug it in
Connect the phone with a USB cable, a data cable, not a charge-only one. This is a real and common failure: if the phone charges but your computer never sees it, try a different cable.
The phone shows a dialog, "Allow USB debugging?" Tick Always allow from this computer and accept. If it does not appear, unplug and replug.
Check your computer can see it:
~/Library/Android/sdk/platform-tools/adb devices -l
List of devices attached
4A11BC0D2E device product:โฆ model:Pixel_10 โฆ
You want the word device. If you see:
unauthorized: the "Allow USB debugging?" dialog was not accepted. Unplug, replug, watch the phone screen.offline: runadb kill-server, then try again.- nothing at all: USB debugging is off, or it is a charge-only cable.
6c. Run it
every dev --android
Same command as before. With the phone plugged in it uses the phone rather than the emulator: builds, installs, launches, and wires up the networking so the app on your phone can reach the dev server on your laptop.
Close the emulator first. If an emulator is running, it wins and the phone is ignored. This is the most confusing thing about this step: everything succeeds, and you are staring at the wrong device.
How does the phone reach localhost? adb sets up a tunnel over the USB
cable, so http://127.0.0.1:3000 on the phone comes out at port 3000 on your
laptop. The URL stays literally correct, which means cookies, redirects and
OAuth callbacks all keep working with no special handling. There is nothing to
configure.
To point the phone at your laptop's network address instead:
every dev --android --dev-url http://192.168.1.20:3000
That needs your dev server bound to 0.0.0.0, and on Rails, that host added to
config.hosts.
6d. Optional: cut the cable
Once it works over USB, you can do all of this over Wi-Fi with the phone in your pocket.
On the phone: Developer options โ Wireless debugging โ Pair device with pairing code. It shows an address and a 6-digit code. On your computer:
ADB=~/Library/Android/sdk/platform-tools/adb
# Use the address + code from the "Pair device" dialog:
"$ADB" pair 192.168.1.42:37105
# Then the address from the main Wireless debugging screen (a DIFFERENT port):
"$ADB" connect 192.168.1.42:41234
The two ports really are different: the pairing one exists only while that
dialog is open. That trips up everyone once. After connecting, the phone appears
in adb devices as normal and every dev --android works the same.
Reading the logs
every logs --android # watch live, Ctrl-C to stop
every logs --android --last 500 # last 500 lines instead of watching
This is Android's log stream, filtered to your app's process, so you see your own output and Hotwire Native's, not the thousands of lines Android emits per minute. The app has to be running.
Step 7: Building an actual app file
every dev is the loop you will live in. When you want a file you can hand to
someone, that is every build:
every build --android
# โ dist/Notes.apk
There is one catch, and it is the last real concept on this page.
Debug vs release, and why your APK will not install
Every Android app must be signed to install. No exceptions: it is how Android knows an update came from the same person who wrote the app.
every dev --androidbuilds a debug version, which Android signs automatically with a standard throwaway key that exists on every developer's machine. That is why Step 6 needed no setup.every build --androidbuilds a release version, and by default it is unsigned, because only you can decide what key to sign it with. An unsigned APK will not install on anything.
So if you every build --android and try to install the result, you get a parse
error or "Failed to collect certificates". Nothing is broken; you have not made
a signing key yet.
Making a signing key
You need this once, ever, per app.
The tool is keytool, which lives inside the JDK, which per Step 1 is not on
your PATH. Use the copy inside Android Studio:
mkdir -p ~/keystores
"/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/keytool" \
-genkeypair -v \
-keystore ~/keystores/notes.jks \
-alias notes \
-keyalg RSA -keysize 2048 -validity 10000
It asks for a password, then some identity questions (name, organization, city). The answers barely matter, but you cannot leave them all blank.
Back this file up, and remember the password. If you publish to Google Play, your app's identity is tied to this key forever. Lose it and you cannot ship an update: you would have to publish a new app and ask every user to reinstall. Put it in a password manager, not just your laptop.
Using it
Signing details come from environment variables, never from everywhere.yml. A
password in a config file is a password in your git history.
export EVERY_ANDROID_KEYSTORE=~/keystores/notes.jks
export EVERY_ANDROID_KEYSTORE_PASSWORD='the password you chose'
export EVERY_ANDROID_KEY_ALIAS=notes
every build --android
# โ dist/Notes.apk, signed and installable
Set some but not all of these and the build stops at once, naming the one you missed.
Getting the signed APK onto a phone
~/Library/Android/sdk/platform-tools/adb install -r dist/Notes.apk
-r means "replace the existing copy, keep its data".
To send it to someone else, by AirDrop, Drive, or email, they need to allow
installs from whatever app they open it with: Settings โ Apps โ Special app
access โ Install unknown apps. Android asks them at the right moment.
adb install skips all of that, which is why it is the better route when the
phone is in front of you.
For Google Play
Play does not take APKs, it takes bundles:
every build --android --format aab
# โ dist/Notes.aab
You cannot install this on a phone. It is an upload format that Play turns into per-device APKs. Upload it in the Play Console, or let the Platform upload it for you over the Play Developer API.
Play also requires a versionCode, an integer that must rise with every
upload. It is derived from your app.version: 1.2.3 becomes 10203. So bump
your version and it is handled. To set it yourself, for a CI build number, use
EVERY_ANDROID_VERSION_CODE=42.
Reference
Common errors
| What you see | What it means |
|---|---|
"com.rubyeverywhere.my-notes" can't be an Android applicationId |
Hyphens are not allowed. Set platforms.android.bundle_id. |
no Android SDK |
Android Studio is not installed, or set ANDROID_HOME. |
โฆ is JDK 11; the Android Gradle Plugin needs 17 or newer |
Unset JAVA_HOME, or point it at a JDK 17+. |
the Android SDK has no platforms;android-36 |
Studio โ SDK Manager โ SDK Platforms โ check Android 16. |
Failed to collect certificates, or a parse error on install |
You are installing an unsigned release APK. Sign it, or use every dev --android. |
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
That app id is already installed, signed with a different key. adb uninstall com.example.notes, or let every dev handle it. It offers to, and warns you the app's data goes with it. |
| The emulator is used instead of my phone | An emulator was already running. Close it. |
adb devices shows nothing |
USB debugging off, dialog not accepted, or a charge-only cable. |
| A wall of Gradle output | The complete log is saved to dist/android-build.log. |
Commands
| Command | Does |
|---|---|
every doctor --target android |
Check the toolchain. |
every dev --android |
Dev loop: server, build, install, launch, on a phone or emulator. |
every dev --android --dev-url URL |
Same, pointing the device at a specific address. |
every build --android |
Release APK โ dist/<Name>.apk. |
every build --android --format aab |
Play bundle โ dist/<Name>.aab. |
every logs --android |
Watch the app's logs. |
every logs --android --serial X |
Pick a device when several are attached. |
Config keys
| Key | Purpose |
|---|---|
remote.url |
Required. Your deployed app. |
platforms.android.bundle_id |
Effectively required. The applicationId. No hyphens. |
app.name, app.version, app.icon |
Label, versionName, launcher icon. |
appearance.tint_color, appearance.background_color |
Theme colors, light and dark. |
tabs[].icons.android |
Material icon name. |
native.android.icon_font |
symbols (default), symbols-rounded, symbols-sharp, classic (offline), none. |
native.android.packages |
Extra Maven dependencies, as "group:artifact:version". |
permissions |
notifications, camera, location, biometrics become manifest entries. Undeclared permissions can never prompt. |
auth.oauth_paths |
Paths that open in a Custom Tab instead of the web view. See iOS โ Third-party sign-in; the config is identical. Rebuild after changing. |
deep_linking.android.package and .sha256_cert_fingerprints |
Make links open your app instead of the browser. |
rules |
Per-route native behavior (modals, pull-to-refresh). Deploys live, no rebuild. |
platforms.android.target_sdk |
Ship ahead of a Play deadline. See everywhere.yml. |
Environment variables
| Variable | Purpose |
|---|---|
ANDROID_HOME |
Non-default SDK location. Rarely needed. |
JAVA_HOME |
Specific JDK, must be 17+. Rarely needed. |
EVERY_ANDROID_KEYSTORE |
Path to your signing keystore. Its absence is what makes a release build unsigned. |
EVERY_ANDROID_KEYSTORE_PASSWORD |
Required alongside it. |
EVERY_ANDROID_KEY_ALIAS |
Required alongside it. |
EVERY_ANDROID_KEY_PASSWORD |
Only if the key has its own password. Defaults to the keystore's. |
EVERY_ANDROID_VERSION_CODE |
Override the derived Play version code. |
Where files live
| Path | What |
|---|---|
~/Library/Android/sdk |
The SDK, on macOS. |
~/.rubyeverywhere/android/<applicationId>/ |
The generated Android project. It is a normal one: open it in Android Studio whenever you are curious. |
~/.rubyeverywhere/android-gradle/ |
Gradle's downloads, shared across apps, about 1.4 GB. |
~/.rubyeverywhere/android-fonts/ |
The downloaded icon font. |
~/.android/avd/ |
Your emulators. |
~/.android/debug.keystore |
The throwaway key debug builds use. |
dist/android-build.log |
The full log of the last build. |
every clean removes the caches under ~/.rubyeverywhere.
Reading a certificate fingerprint
For deep_linking.android.sha256_cert_fingerprints:
JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin"
# Your release key
"$JBR/keytool" -list -v -keystore ~/keystores/notes.jks -alias notes | grep SHA256
# The debug key, for testing deep links against a dev build
"$JBR/keytool" -list -v -keystore ~/.android/debug.keystore \
-alias androiddebugkey -storepass android -keypass android | grep SHA256
If you use Play App Signing, the fingerprint that counts is the one Play shows under Release โ Setup โ App signing, not your upload key's.
Native Kotlin extensions
Drop .kt files into native/android/ and images into
native/android/assets/, where they become R.drawable.*. Every Kotlin file
must begin with:
package com.rubyeverywhere.shell.extensions
Details in Native extensions.
Technical notes
- The minimum supported Android version is 9.0 (API 28). The shell compiles against and targets API 36 (Android 16), which is Google Play's floor for new uploads from 2026-08-31.
- Apps with
remote.instances: truepermit cleartext HTTP wholesale. Android's network security config has no way to scope an exception to private network ranges the way iOS does. - Building Android on the Platform needs no local SDK at all: hosted Linux runners build and sign for you.