Docs

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.url is 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 .zshrc or add anything to your PATH. Android Studio installs all these tools and exports none of them, so on a perfectly working machine which adb prints 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

Terminal bash
cd my_app
every doctor --target android
Output text
โ†’ 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:

Terminal bash
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:

config/everywhere.yml yaml
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.

Code yaml
# โœ… 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

config/everywhere.yml yaml
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.

Terminal bash
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 a to rebuild and reinstall the Android app
  • press l to 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:

  1. Settings โ†’ About phone
  2. Find Build number and tap it seven times. It counts down at you, then says "You are now a developer."
  3. Go back to Settings โ†’ System โ†’ Developer options, a new menu that just appeared
  4. 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:

Terminal bash
~/Library/Android/sdk/platform-tools/adb devices -l
Output text
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: run adb kill-server, then try again.
  • nothing at all: USB debugging is off, or it is a charge-only cable.

6c. Run it

Terminal bash
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:

Terminal bash
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:

Terminal bash
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

Terminal bash
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:

Terminal bash
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 --android builds 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 --android builds 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:

Terminal bash
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.

Terminal bash
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

Terminal bash
~/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:

Terminal bash
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:

Terminal bash
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:

Code kotlin
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: true permit 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.
Rails ยท Hanami ยท Sinatra โ€” built with Ruby