Skip to content
April 07, 2026 · 7 min read Ruby on Rails

Rails 8 to Native Android in 200 Lines of Kotlin — Part 1: The WebView Shell

We took a full-featured school management portal — serving 2,500+ students and 150+ staff across admin, teacher, student, and parent portals — and wrapped it in a native Android shell. Bottom tab navigation, branded splash screen, persistent sessions. The entire Android app is under 200 lines of Kotlin.

Series:* 3-part tutorial on turning any Rails 8 app into a native Android app using Hotwire Native patterns — no React Native, no Flutter, no rewrite.

Your Rails app is already mobile-responsive. What if it felt like a real app on someone's phone?

We took a full-featured school management portal — serving 2,500+ students and 150+ staff across admin, teacher, student, and parent portals — and wrapped it in a native Android shell. Bottom tab navigation, branded splash screen, persistent sessions. The entire Android app is under 200 lines of Kotlin.


Who This Is For

  • Rails developers who want to ship a mobile app without learning React Native
  • Teams that already have a mobile-responsive web app
  • Businesses that need an internal app distributed outside the Play Store
  • Anyone who thinks "we need a mobile app" but doesn't have mobile developers

Why Not PWA?

Progressive Web Apps work, but on Android they have real limitations:
- Inconsistent home screen icons — some devices show generic browser icons
- No bottom tab navigation — PWAs get the browser's navigation, not custom tabs
- Limited push notification reliability — browsers throttle web push on Android
- No camera/mic access in embedded iframes — critical if you use video calling
- No auto-update mechanism — users don't know when features ship
- No splash screen control — the PWA splash is basic and uncustomisable

A native WebView wrapper solves all of these. And because your entire UI stays in Rails, there's nothing to rewrite.

The Architecture

text
┌─────────────────────────────────────────┐
│  Android App (Kotlin)                   │
│                                         │
│  ┌───────────────────────────────────┐  │
│  │  MainActivity                     │  │
│  │  - Bottom Navigation (5 tabs)     │  │
│  │  - Fragment management            │  │
│  │  - Deep link handling             │  │
│  └───────────────────────────────────┘  │
│                                         │
│  ┌───────────────────────────────────┐  │
│  │  WebViewFragment (per tab)        │  │
│  │  - WebView with JS enabled        │  │
│  │  - Cookie persistence             │  │
│  │  - Custom user agent              │  │
│  │  - External URL handling          │  │
│  └───────────────────────────────────┘  │
│                                         │
│         ▼ WebView loads ▼               │
│  ┌───────────────────────────────────┐  │
│  │  https://yourapp.com              │  │
│  │  (Your Rails app — unchanged)     │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

The Rails app doesn't know (or care) that it's running inside an Android WebView. It serves the same responsive HTML it serves to any mobile browser. The native shell provides the chrome around it.

Step 1: Project Setup

Create a new Android project in Android Studio:
- Language: Kotlin
- Minimum SDK: API 24 (Android 7.0 — covers 95%+ of devices)
- Template: Empty Activity
- Package: com.yourcompany.yourapp

Dependencies — you only need AndroidX and WebKit. No Turbo Native library needed (we're going lightweight):

gradle
dependencies {
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.11.0'
    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
}

Step 2: The Layout

Two layouts — the main activity with a fragment container and bottom navigation, and the web fragment with a WebView:

activity_main.xml:
```xml
<?xml version="1.0" encoding="utf-8"?>
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

text
<FrameLayout
    android:id="@+id/fragment_container"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1" />

<com.google.android.material.bottomnavigation.BottomNavigationView
    android:id="@+id/bottom_navigation"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:menu="@menu/bottom_nav" />


```

fragment_web.xml:
```xml
<?xml version="1.0" encoding="utf-8"?>
android:layout_width="match_parent"
android:layout_height="match_parent">

text
<WebView
    android:id="@+id/turbo_web_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

<!-- Loading overlay shown on first launch -->
<View
    android:id="@+id/loading_overlay"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/forest_green" />


```

Step 3: The WebViewFragment

This is the core of the app — a Fragment that hosts a configured WebView:

kotlin
class WebViewFragment : Fragment() {

    private var webView: WebView? = null
    private var currentUrl: String = ""

    @SuppressLint("SetJavaScriptEnabled")
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        webView = view.findViewById(R.id.turbo_web_view)
        currentUrl = arguments?.getString(ARG_URL) ?: "${BuildConfig.BASE_URL}/"

        webView?.apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            settings.mediaPlaybackRequiresUserGesture = false
            settings.userAgentString =
                "${settings.userAgentString} MyApp Android/1.0 Turbo Native"

            // Cookie persistence — critical for Devise sessions
            CookieManager.getInstance().apply {
                setAcceptCookie(true)
                setAcceptThirdPartyCookies(this@apply, true)
            }

            webViewClient = object : WebViewClient() {
                override fun shouldOverrideUrlLoading(
                    view: WebView?, request: WebResourceRequest?
                ): Boolean {
                    val url = request?.url?.toString() ?: return false
                    // External URLs open in system browser
                    if (!url.contains("yourapp.com")) {
                        startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
                        return true
                    }
                    return false
                }

                override fun onPageFinished(view: WebView?, url: String?) {
                    super.onPageFinished(view, url)
                    CookieManager.getInstance().flush()
                }
            }

            webChromeClient = WebChromeClient()
        }

        webView?.loadUrl(currentUrl)
    }

    fun reload() { webView?.reload() }
    fun canGoBack(): Boolean = webView?.canGoBack() ?: false
    fun goBack() { webView?.goBack() }
}

Critical settings explained:
- javaScriptEnabled = true — your Rails app needs JS (Turbo, Stimulus, ActionCable)
- domStorageEnabled = true — localStorage for Turbo cache, session tokens
- mediaPlaybackRequiresUserGesture = false — allows video/audio to play without tap (needed for video calls)
- Custom user agent — lets your Rails app detect the native shell
- Cookie flush on page finish — ensures Devise sessions persist across app restarts

Step 4: Bottom Tab Navigation with Fragment Caching

The MainActivity manages tabs. Each tab creates a WebViewFragment pointed at a URL, then caches it:

kotlin
class MainActivity : AppCompatActivity() {

    private val tabs = mapOf(
        R.id.nav_home to "/",
        R.id.nav_portal to "/dashboard",
        R.id.nav_notifications to "/notifications",
        R.id.nav_profile to "/profile"
    )

    private var currentTabId = R.id.nav_home
    private var activeFragment: Fragment? = null

    private fun loadTab(tabId: Int) {
        val tag = "tab_$tabId"
        val transaction = supportFragmentManager.beginTransaction()

        // Hide current fragment (don't destroy — preserves state)
        activeFragment?.let { transaction.hide(it) }

        // Find or create the target fragment
        var fragment = supportFragmentManager.findFragmentByTag(tag)
        if (fragment == null) {
            val url = "${BuildConfig.BASE_URL}${tabs[tabId]}"
            fragment = WebViewFragment.newInstance(url)
            transaction.add(R.id.fragment_container, fragment, tag)
        } else {
            transaction.show(fragment)
        }

        transaction.commit()
        activeFragment = fragment
        currentTabId = tabId
    }
}

Why hide/show instead of replace? When you replace a fragment, the old one is destroyed and recreated when you switch back. That means the WebView reloads the page — losing scroll position, form state, and requiring a fresh HTTP request. Hide/show keeps all fragments alive in memory, giving instant tab switching.

Step 5: Server-Side Detection

On the Rails side, detect the native app and adjust the UI:

ruby
# app/helpers/application_helper.rb
def hotwire_native_app?
  request.user_agent&.include?("Turbo Native")
end

In your layouts:

erb
<% unless hotwire_native_app? %>
  <!-- Hide web navigation — native app has bottom tabs -->
  <nav class="main-nav">...</nav>
<% end %>

<main class="<%= hotwire_native_app? ? 'pb-16' : '' %>">
  <%= yield %>
</main>

<% unless hotwire_native_app? %>
  <footer>...</footer>
<% end %>

The same Rails views serve both web and native — no duplication. The native app gets a cleaner view without redundant navigation, and bottom padding accounts for the tab bar.

Step 6: The Splash Screen

Android shows a blank screen while the WebView loads. Add a loading overlay that fades out on first page load:

kotlin
override fun onPageFinished(view: WebView?, url: String?) {
    super.onPageFinished(view, url)

    loadingOverlay?.let { overlay ->
        if (overlay.visibility == View.VISIBLE) {
            overlay.animate()
                .alpha(0f)
                .setDuration(400)
                .withEndAction { overlay.visibility = View.GONE }
                .start()
        }
    }
}

Set the overlay to your brand colour (@color/forest_green) — users see a solid branded screen for 1-2 seconds, then it fades into the web app. Feels native.

Step 7: Build Configuration

In build.gradle, set your base URL as a build config field:

gradle
android {
    defaultConfig {
        buildConfigField "String", "BASE_URL", "\"https://yourapp.com\""
    }
}

This lets you use BuildConfig.BASE_URL throughout the Kotlin code. For development, create a debug variant pointing to your local server.


What You Have So Far

At this point your app:
- Opens in a native Android shell with your app icon
- Shows bottom tab navigation (Home, Portal, Notifications, Profile)
- Preserves login sessions across app restarts
- Opens external URLs in the system browser
- Shows a branded splash while loading
- Hides web navigation when detected as native

What's missing: Auto-updates, video call permissions, and the scroll conflict fix. We'll handle those in Parts 2 and 3.


Next: Part 2 — Auto-Updates Without the Play Store

We'll build an update checker that calls your Rails API, shows update prompts (with force-update support), and distributes APKs directly from your server.

Need help wrapping your Rails app? Get in touch — I've shipped this pattern to production.

Filed under Ruby on Rails

Discussion

Comments (3)

Leave a comment

OT
OTHAN 5 months ago

Wow. Been lookin for this...this is so good....observed you for some years now you have really upped your game...I really hope the market comes after you. Nice post...surely gonna follow through.

Ju
Juwon 5 months ago

Thanks man... it gets better with time.

ha
hart 5 months ago

great content. This field is still green.

Enjoyed this post?

Subscribe to get notified about new articles.