RailsFast Native - Android
RailsFast Android is a thin Hotwire Native Android shell around your RailsFast Base app. This page gets you from git clone to a running app pointed at your Rails server, then walks the two-activity architecture, native tabs, bridge components, base URL config, deep links, backup rules, and the rename script you run to make the project yours.
Android is a thin shell, not a second product. Its job is to bootstrap Hotwire correctly, present the right native shell, keep Android-specific behavior polished, and consume the Rails server contract cleanly. Rails stays the product brain. See Native Overview for the full split.
Clone, Point, Run
You need three things before the app does anything useful:
- A running RailsFast Base server (your "product brain"). The shell is not standalone — it renders Rails screens.
- Android Studio with a recent toolchain. The project uses AGP 9.1.0 and Kotlin 2.2.10, and
compileSdkuses the newerrelease(36) { minorApiLevel = 1 }DSL. An older Android Studio may not parse the build files, so update Android Studio first if Gradle sync fails. - An emulator or device on minSdk 28 or higher (
targetSdk/compileSdkare 36, Java 11).
The Android emulator reaches a Rails server running on your Mac through the host alias http://10.0.2.2:3000, not localhost. That is the template default base URL, so a stock clone pointed at a local bin/dev server just works on the emulator.
Once your Rails server is up, install and run the debug build:
# from the railsfast-android repo root
./gradlew installDebug
Or just press Run in Android Studio. The app launches into /native/entry, Rails decides where to send you, and the shell takes it from there.
To point the build at a different host (a remote dev box, staging, production), pass the base URL as a Gradle property:
./gradlew installDebug -PrailsfastBaseUrl=https://app.example.com
Do not commit a downstream production host as the template default. Keep gradle.properties on the emulator alias and override per-build with -PrailsfastBaseUrl=..., or set your real domain once you run the rename script.
Version Pin
Hotwire Native Android is pinned to 1.2.8 via the hotwireNative version ref in gradle/libs.versions.toml. Both dev.hotwire:core and dev.hotwire:navigation-fragments share that single ref, so they always move together.
# gradle/libs.versions.toml
hotwireNative = "1.2.8"
The Hotwire Native version is cross-platform-coupled. Keep Android in step with the version RailsFast iOS is pinned to so both shells consume the same framework behavior. Bumping it can shift bridge and bottom-navigation APIs, so re-test the shell after any change.
The Two-Activity Architecture
RailsFast Android uses two activities, both HotwireActivity subclasses that implement NativeShellHost:
AuthActivity— the signed-out / standalone shell. A single navigator namedstandalone, hosting/native/entry, the welcome screen, auth sheets, and onboarding that must run outside tabs.MainActivity— the signed-in tab shell. Bottom navigation with one navigator host per tab.
This maps directly to the shell states Rails publishes: auth and onboarding live in AuthActivity; tabs lives in MainActivity.
The two-shell split keeps auth and setup from feeling trapped inside the main app tabs.
When Rails signals a shell-state change, the current activity starts the other one and finish()es itself, with activity transition animations suppressed (ActivityTransitions.kt) so the swap reads as a root replacement, not a screen navigation.
AuthActivity is the only entrypoint. It is exported=true, the MAIN/LAUNCHER activity, and the VIEW/BROWSABLE/DEFAULT deep-link target. MainActivity is exported=false with no intent filters — it is only ever reached via the AuthActivity handoff or an explicit intent.
Main Files
App bootstrap:
app/src/main/java/com/railsfast/android/RailsFastApplication.kt— all Hotwire setup happens once inonCreateapp/src/main/java/com/railsfast/android/AppConfiguration.kt— base URL, derived URLs, launch-location canonicalizationapp/src/main/assets/json/path-configuration.json— bundled fallback path config
Signed-out shell:
app/src/main/java/com/railsfast/android/AuthActivity.ktapp/src/main/res/layout/activity_auth.xmlapp/src/main/java/com/railsfast/android/AuthSheetFragment.kt
Signed-in tab shell:
app/src/main/java/com/railsfast/android/MainActivity.ktapp/src/main/res/layout/activity_main.xmlapp/src/main/java/com/railsfast/android/MainTabs.kt— the compile-time tab catalog and route-family matchingapp/src/main/java/com/railsfast/android/TabShellRouteDecisionHandler.kt— the cross-tab routerapp/src/main/java/com/railsfast/android/NativeShellConfiguration.kt— parsessettings.shellfrom path config
Shell, fragments, bridge, and infrastructure:
app/src/main/java/com/railsfast/android/WebFragment.ktapp/src/main/java/com/railsfast/android/NativeShellHost.kt,NativeShellState.kt,ActivityTransitions.ktapp/src/main/java/com/railsfast/android/NativeHttpClient.kt— cookie-forwarding GET client for native screensapp/src/main/java/com/railsfast/android/NativeTabSelectionAware.kt— defers work on eagerly-created tab rootsapp/src/main/java/com/railsfast/android/bridge/—NativeShellComponent,MenuComponent,OverflowMenuComponent,ToastComponentapp/src/main/java/com/railsfast/android/features/me/— the Compose-native Account screen
What RailsFastApplication Sets Up
All Hotwire configuration is centralized in RailsFastApplication.onCreate() via configureHotwire(), following the official Android demo shape rather than per-activity setup. It wires up:
- Path configuration — both the local asset (
json/path-configuration.json) and the remote URL ($baseUrl/native/configurations/android/v1.json). - Debug flags —
debugLoggingEnabledandwebViewDebuggingEnabledtrackBuildConfig.DEBUG. - The user-agent prefix (see below).
- The default fragment destination —
WebFragment. - Route decision handlers (see below).
- Fragment destinations —
WebFragment,AuthSheetFragment,NativeMeFragment. - Bridge components —
native-shell,menu,overflow-menu,toast.
Keeping both local and remote path configuration is intentional. The local file is your safe fallback when the remote contract is unavailable, stale, or has not loaded yet.
Backup exclusion rules are not configured here — they live in the manifest and res/xml/. See Backup Rules below.
Tabs Are Native
RailsFast Android uses the stock Hotwire Native bottom tab system (HotwireBottomNavigationController, Hotwire 1.2+). Tabs are not bridge components — the tab bar is a native shell concern, while bridge components are for progressive native enhancements and app-shell signals.
Hotwire ships no built-in authed-vs-signed-out shell, so the two-activity pattern and the tab catalog are the app-layer contract that fills that gap.
How Tab Ownership Works
This is one of the most important concepts in the Android app.
Tabs are not owned only by their start URL. They are owned by route families.
The default tabs and the route families they own:
home—/dashboardfamily (^/dashboard(?:/.*)?$), starts at/dashboardaccount—/native/mefamily (^/native/me(?:/.*)?$), starts at/native/me(the native Compose screen)settings—/settings,/organizations,/memberships,/invitations, and/billingfamilies, starts at/settings
Each family is a regex matched against the URL path only. Query strings are stripped first, so /settings?return_to=/dashboard still belongs to the settings tab. The settings tab deliberately owns the whole ^/billing(?:/.*)?$ family (and the org/membership/invitation families) so those pages stay in the Settings navigator instead of flickering between tabs.
Tab ownership is the same SSOT on every platform. MainTabs merges the server's settings.shell block (from path config) over the hardcoded supported-tab defaults, and the values must stay identical to what Rails serves at /native/configurations/android/v1.json and to the iOS catalog. If they drift, cross-tab routing silently sends users to the wrong tab. If you only map tabs by their root URL, navigation feels broken fast.
MainTabs.Catalog.tabFor(location) returns null when no route pattern claims the URL. That is a meaningful value: it means tab-agnostic — push the URL onto the tab the user is currently on rather than forcing a jump to the default tab. The stock AppNavigationRouteDecisionHandler does that in-tab push.
tabFor() returning null means "stack on the current tab," NOT "use the default tab." Only cold launch falls back to defaultTab() — when the app starts from an unclaimed deep link there is no current tab yet, so MainActivity starts on the configured default and routes the URL there once the navigator is ready.
The Route Decision Handler Order
Hotwire.registerRouteDecisionHandlers(...) replaces the entire handler list — it does not append. The Router stops at the first matching handler, so order is load-bearing:
// app/src/main/java/com/railsfast/android/RailsFastApplication.kt
Hotwire.registerRouteDecisionHandlers(
TabShellRouteDecisionHandler(), // custom — MUST be first
AppNavigationRouteDecisionHandler(), // stock — normal in-app push
BrowserTabRouteDecisionHandler(), // stock — external links in a Custom Tab
SystemNavigationRouteDecisionHandler() // stock — system navigation
)
Register TabShellRouteDecisionHandler first so cross-tab internal URLs switch tabs before the stock handler treats them as a normal push. And keep the three stock handlers registered after it — because the call replaces the whole list, dropping them breaks external links (Custom Tab) and same-tab navigation.
TabShellRouteDecisionHandler only matches when the source navigator's tab differs from the tab that owns the target URL. On a match it routes cross-tab inside MainActivity and cancels the stock navigation; otherwise it falls through.
Android eagerly creates all bottom-tab roots up front, so hidden WebViews in background tabs can fire bridge messages or finish visits. The shell guards every shell-affecting path with acceptsShellUpdate / background-source checks, and native screens defer real work to NativeTabSelectionAware.onTabSelected. This is the single biggest Android-vs-iOS behavioral difference to keep in mind.
Bridge Components
RailsFastApplication registers four bridge components. Rails renders the matching hidden markup only when the component is advertised in the user agent (see User Agent), so these are gated by capability, not platform.
native-shell
NativeShellComponent listens for the connect event and reads {state, handoff, location}. State maps via NativeShellState.from() to AUTH / ONBOARDING / TABS, then the activity swaps shells. It ignores modal destinations unless handoff=true (the auth-success handoff document is the one modal allowed to reshape the root), ignores invisible fragments, and rejects updates from a navigator that is not the currently accepted one. This is the app-defined shell contract Hotwire does not provide out of the box.
toast
ToastComponent listens for show with the shared payload:
{
"message": "Saved",
"severity": "success"
}
Platform mapping:
success(and unknown/legacy severities) → a short AndroidToasterror,warning,info→ a MaterialSnackbaranchored on the fragment's own view, with an OK dismiss action
Non-success snackbars are LENGTH_INDEFINITE on purpose: auth and validation messages can appear while the keyboard or a sheet is up, and short durations are easy to miss. Snackbars anchor on the fragment view (never android.R.id.content) to stay visible above modals and sheets. The severity vocabulary (success/error/warning/info) mirrors the Rails HotwireNativeHelper flash normalization and the iOS toast component.
menu
MenuComponent listens for display with {title, items: [{title, index}]} and renders a Material BottomSheetDialog. On selection it replies {selectedIndex: <index>} with the server-provided index — the web stays the source of truth for what each action does. RailsFast Base uses this for the session-actions menu (Sign out).
overflow-menu
OverflowMenuComponent listens for connect with {label}, makes the toolbar's overflow item (inflated from R.menu.web) visible with that label, and replies connect when tapped so the web page can trigger the menu. The web page decides whether the overflow affordance exists; native only presents it.
The bridge contract — event names, payload schemas, reply schemas — is shared across web, Android, and iOS. If you add a bridge component, give it a Rails-rendered trigger, a web BridgeComponent controller in RailsFast Base, and a matching native component registered in RailsFastApplication. Keep the native side small.
References:
The Native Account Screen (Compose)
The Account tab is the reference recipe for a fully native screen. Copy this split for any new native screen instead of putting networking or route parsing inside Compose:
NativeMeFragment(aHotwireFragment, alsoNativeTabSelectionAware) hosts aComposeViewand owns the Hotwire destination.NativeMeViewModelowns loading / retry / unauthorized state and fetches off the Compose tree.NativeMeScreenis the pure Compose UI.
The view model builds a .json URL via AppConfiguration.jsonLocation(location) and fetches it through NativeHttpClient.get(...) on Dispatchers.IO. A 401 routes back to /native/entry (the session is gone, so converge on the auth shell). Because Android eagerly creates every tab root, the fetch is deferred to NativeTabSelectionAware.onTabSelected rather than firing while the tab is still hidden.
The JSON shape Rails serves at /native/me.json:
{
"user": { "email": "...", "confirmed": true, "created_at": "2026-05-28T00:00:00Z" },
"organization": { "name": "Acme" },
"account": { "plan_name": "Free", "credits": 0, "onboarding": false }
}
Cookie Forwarding Is Manual on Android
NativeHttpClient is the load-bearing auth-continuity detail for native screens:
- It ships
get, pluspostJson/patchJsonwrite helpers (verb-agnosticexecute()with an optional per-call timeout). But cookie sync only solves authentication, not CSRF — a native write still needs a server-side CSRF policy before it will succeed (see the native write contract). Until you add that, treat reads as the safe default. - OkHttp does not reuse the WebView's cookies automatically (unlike WebView-backed screens), so the client manually copies the
Cookieheader fromCookieManager.getInstance().getCookie(location)onto each request.
// app/src/main/java/com/railsfast/android/NativeHttpClient.kt
val cookieHeader = cookies.getCookie(location)
cookieHeader?.let { requestBuilder.header("Cookie", it) }
Any new native screen that needs an authenticated request must rely on this manual forwarding — OkHttp will not carry the Rails session by itself. And cookie forwarding solves authentication, not CSRF. Native writes still need an explicit server-side CSRF policy (the blessed pattern is skip_forgery_protection scoped to JSON for authenticated native-only endpoints). See RailsFast Base for Native.
Auth Sheets
Sign up and sign in both route through a single Android rule — hotwire://fragment/web/modal/auth-sheet — handled by AuthSheetFragment (a HotwireWebBottomSheetFragment).
Android ignores modal_style entirely. Where iOS uses large for signup and medium for signin, Android has one combined auth-sheet rule and always opens the sheet at STATE_EXPANDED with skipCollapsed=true. Auth forms plus a CAPTCHA / Turnstile widget are cramped in the collapsed peek state, so the sheet starts fully expanded.
The signed-out welcome screen stays web-rendered underneath the sheet, so dismissing it can briefly repaint the underlying WebView.
User Agent
The Android template prefix is:
// app/src/main/java/com/railsfast/android/RailsFastApplication.kt
"${BuildConfig.APPLICATION_NAME} Android; RailsFast Native Android;"
Hotwire Native appends the framework identity and bridge component list after the prefix:
{AppName} Android; RailsFast Native Android; Hotwire Native Android; Turbo Native Android; bridge-components: [...]
Do not duplicate the framework strings in the app prefix. The prefix is layered so Rails can feature-detect at three levels:
hotwire_native_app?— any Hotwire app (framework level)railsfast_native_app?— any RailsFast native app (template level, theRailsFast Native Androidsegment)- the app-specific
{AppName}segment — your specific app
Bridge output (toast, menu, overflow-menu) is gated by the advertised bridge-components: [...] segment, not by platform. Rails renders a bridge tag only when the component name appears in that list. If both Android and iOS register toast, Rails sends the same toast payload to both. This is the governing cross-platform rule, which is why the UA segment matters.
Base URL and Build Identity
Base URL resolution happens in app/build.gradle.kts:
- The
-PrailsfastBaseUrl=...Gradle property wins if present. - Otherwise
railsfastDefaultBaseUrl, which derives from arailsfast.ymldomainashttps://<domain>(unless the domain is the placeholderexample.com), and otherwise falls back to thegradle.propertiesvalue (http://10.0.2.2:3000).
The resolved value is exposed as BuildConfig.BASE_URL. AppConfiguration.baseUrl strips the trailing slash, and from it derives nativeEntryLocation (+ /native/entry) and androidPathConfigurationUrl (+ /native/configurations/android/v1.json).
There are no Gradle product flavors. Build identity — app label, application ID/namespace, base URL, app-link host — comes entirely from gradle.properties, -P properties, the optional RAILSFAST_CONFIG_PATH import, and an optional railsfast.yml. Only debug and release build types exist (and release does not minify).
The README and bin/rename show http://10.0.2.2:3001 in their examples, but the committed gradle.properties default is :3000. Match the port to whatever your bin/dev Rails server actually listens on.
Deep Links and App Links
AuthActivity is both the launcher and the app-link entrypoint. Its VIEW intent filter uses Gradle manifestPlaceholders derived from the base URL (deepLinkScheme, deepLinkHost) and android:autoVerify="${deepLinkAutoVerify}".
deepLinkAutoVerify is computed in Gradle and is true only when the scheme is https, the host is not localhost / 127.0.0.1 / 10.0.2.2, and the host is not a raw IPv4 address.
Local emulator hosts (10.0.2.2) still get a VIEW filter, so you can test deep links with an explicit adb intent — but they will never auto-open from Chrome, because they cannot pass Android App Links verification. Real browser-to-app auto-open needs a real https domain plus /.well-known/assetlinks.json served by your Rails app. RailsFast Base serves that file (and it fails closed with [] until you configure a package name and signing fingerprint). See Verified App Links.
AppConfiguration.launchLocation canonicalizes the incoming URL: host-less paths get a leading /, same-host URLs pass through, localhost/127.0.0.1 are rewritten to the base host (so Hotwire does not treat dev hosts as external), and any other host is rejected as external.
Backup Rules
RailsFast Android keeps the WebView browser profile out of Android backup and device-transfer payloads. This is configured in the manifest and the res/xml/ rule files, not in the Hotwire bootstrap:
app/src/main/AndroidManifest.xml—allowBackup=truewithfullBackupContent=@xml/backup_rulesanddataExtractionRules=@xml/data_extraction_rulesapp/src/main/res/xml/backup_rules.xml— excludesapp_webview/from therootdomain (legacy full-backup)app/src/main/res/xml/data_extraction_rules.xml— excludesapp_webview/under both<cloud-backup>and<device-transfer>(Android 12+)
RailsFast uses server-owned sessions. Restoring another install's WebView cookie/cache/profile store could resurrect stale cookies or an incompatible browser profile, so the WebView data directory is deliberately kept device-local. Directory excludes are recursive.
Reference: Android Auto Backup include/exclude rules.
Make the Project Yours: bin/rename
The template ships with the placeholder package com.railsfast.android. The code namespace stays on that placeholder until you rename the source tree. Run the rename script to make the project yours:
bin/rename
It prompts for (or accepts as flags) --package, --app-name, --base-url, --railsfast-config, --commit, and --yes. It:
- validates the package name against a Java-package regex
- upserts
railsfastNamespace,railsfastApplicationId,railsfastApplicationName, andrailsfastDefaultBaseUrlingradle.properties - physically moves the Kotlin package tree under
app/src/{main,test,androidTest}/java - rewrites package references in all
.kt/.javafiles - can optionally update
native.android.package_namein arailsfast.yml - can
git-commit the rename when--commitis passed
The build never assumes a sibling railsfastbase/ checkout. Importing shared identity from a railsfast.yml is opt-in only (-PrailsfastConfigPath=/absolute/path/to/config/railsfast/railsfast.yml), because RailsFast projects are typically cloned and renamed immediately during quickstart.
Where To Customize Android
Change the base URL
Edit gradle.properties (railsfastDefaultBaseUrl) for the committed default, or pass -PrailsfastBaseUrl=... per build. AppConfiguration.kt derives everything else from BuildConfig.BASE_URL.
Change the launcher icon and splash screen
Use App Icons and Launch Screens for the complete workflow. Android needs separate adaptive foreground/background layers, a monochrome layer for themed icons, generated legacy mipmaps, and intentional day/night splash colors. Do not reuse the already-masked iOS icon as the Android adaptive foreground.
Change the supported native tabs
Edit MainTabs.kt, activity_main.xml (the navigator hosts), and the local fallback path-configuration.json if your default shell policy changes. Use this when adding a tab, changing tab titles or icons, or adding navigator hosts.
Tab route-family ownership is a product rule. If you change which routes a tab owns, change it in RailsFast Base path config first (so it can change without an app review), then keep the Android catalog defaults in sync as the offline fallback.
Change shell handoff behavior
Edit AuthActivity.kt, MainActivity.kt, and ActivityTransitions.kt — only if the app's shell architecture actually changes.
Change Android-only presentation behavior
Edit WebFragment.kt (toolbar visibility, server-driven hide_navigation_bar) and AuthSheetFragment.kt (sheet sizing). Use this for genuinely Android-only fragment behavior.
Native title fonts
Most of what you see on a tab root is your Rails HTML, so it already uses whatever fonts your web app loads — there is nothing to do on the Android side for that chrome.
The surfaces that are actually native Android views, and therefore render in the system font, are narrow: the bottom-tab labels and the pushed-screen toolbar title.
Unlike iOS — where the navigation bar is always native UIKit chrome and the template ships a dedicated font hook — RailsFast Android does not ship a font-theming hook, because tab roots render their branded header in the web view (HTML/CSS) and there is far less native chrome to style. The starter intentionally leaves these few native labels on the system font.
If you do want to brand those native labels, it is plain Android theming, not a RailsFast mechanism: drop your .ttf/.otf into res/font, point your toolbar/tab textAppearance at it in your theme, and use a shared Typography for any Compose-native screens. Reach for this only if matching the native labels to your web type is worth carrying the font in the binary — for most apps the system font next to your web header looks perfectly native.
When To Put Something In Android Instead of Rails
Put it in Android only when it is a true Android container concern.
Belongs in Android: activity structure, fragments, bottom navigation, Android-only transitions, Android-only native UI.
Belongs in RailsFast Base instead: route ownership rules, tab policy, product onboarding rules, billing/pricing policy. When in doubt, ask the question from the overview — is this a product rule or a native container detail?