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

Rails 8 to Native Android in 200 Lines of Kotlin — Part 3: Video Permissions, Scroll Fixes, and Production Polish

In Parts 1 and 2, we built the WebView shell with tabs and auto-updates. Now we tackle the three things that separate a working prototype from a production app: camera/microphone permissions for video calls, the scroll conflict that plagues every WebView app, and the distribution polish that makes it feel professional.

The Camera/Microphone Problem

If your Rails app embeds video calling (Jitsi, Twilio, Daily.co — anything using WebRTC), it won't work in the Android WebView out of the box. Here's why:

  1. Your web page requests camera/mic access via the browser's getUserMedia API
  2. In a regular browser, Chrome shows the permission prompt and handles everything
  3. In a WebView, the request hits WebChromeClient.onPermissionRequest — which denies everything by default
  4. The video call silently fails or shows "camera not available"

The Fix: Three Layers of Permission

Layer 1: AndroidManifest.xml

Declare that your app uses camera and microphone hardware:

xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

Without these, Android won't even show the permission dialog — the request silently fails.

Layer 2: WebChromeClient Override

Override onPermissionRequest to bridge the web request to Android's permission system:

kotlin
webChromeClient = object : WebChromeClient() {
    override fun onPermissionRequest(request: PermissionRequest?) {
        request ?: return

        // Map web permission types to Android permission strings
        val needed = mutableListOf<String>()
        request.resources.forEach { resource ->
            when (resource) {
                PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
                    needed.add(Manifest.permission.CAMERA)
                PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
                    needed.add(Manifest.permission.RECORD_AUDIO)
            }
        }

        // Check current state and request if needed
        // On grant: request.grant(request.resources)
        // On deny: request.deny()
    }
}

Layer 3: Runtime Permission Request

Android requires runtime permission requests for camera and microphone (not just manifest declarations). You need to:

  1. Check if permission is already granted
  2. If not, store the PermissionRequest reference
  3. Launch the Android permission dialog
  4. In the callback, grant or deny the stored request

The ActivityResultContracts.RequestMultiplePermissions() API handles this cleanly without the old onRequestPermissionsResult boilerplate.

The iframe allow Attribute

On the Rails side, your video iframe needs the correct permission policy:

erb
<iframe
  src="<%= video_url %>"
  allow="camera *; microphone *; fullscreen *; display-capture *; autoplay *; encrypted-media *"
  allowfullscreen>
</iframe>

The * wildcard is essential — it grants the permission to the cross-origin iframe (your Jitsi server inside your app domain). Without the wildcard, mobile browsers (and WebViews) silently block the permission delegation.

The Complete Flow

text
User joins video call in app
  → iframe requests getUserMedia()
  → WebView fires onPermissionRequest(RESOURCE_VIDEO_CAPTURE, RESOURCE_AUDIO_CAPTURE)
  → Kotlin checks Android permissions
  → First time: shows "Allow camera?" system dialog
  → User taps "Allow"
  → Kotlin calls request.grant(request.resources)
  → WebView allows getUserMedia()
  → Video call works
  → Subsequent calls: permission already granted, no dialog

The Scroll Conflict

This is the single most frustrating issue with WebView-based apps, and almost no tutorials address it properly.

The Problem

Android's SwipeRefreshLayout (pull-to-refresh) intercepts vertical scroll gestures at the top of the page. When the user scrolls up inside the web content and reaches the top, further upward scrolling triggers the refresh gesture instead of bouncing the content. The result: the page gets stuck and won't scroll back up properly.

In our case, teachers were trying to scroll up through attendance records and the page would freeze, then reload unexpectedly.

Solutions We Tried (And Why They Failed)

1. Disable SwipeRefreshLayout on scroll up
kotlin
webView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
swipeRefresh.isEnabled = scrollY == 0
}

Inconsistent — race condition between scroll position detection and gesture recognition.

2. CSS viewport height units
css
height: 100dvh; /* dynamic viewport height */

Helped with some layout issues but didn't fix the gesture conflict.

3. -webkit-overflow-scrolling: touch
No effect in modern Android WebView.

4. Custom SwipeRefreshLayout subclass
Overriding canChildScrollUp() — worked sometimes, failed in nested scroll containers.

The Solution That Actually Works

Disable SwipeRefreshLayout entirely. Add a Refresh button to the bottom tab bar.

kotlin
swipeRefresh?.isEnabled = false

In the bottom navigation menu:
xml
<item
android:id="@+id/nav_refresh"
android:icon="@drawable/ic_refresh"
android:title="Refresh" />

Handle it in MainActivity:
kotlin
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (item.itemId == R.id.nav_refresh) {
(activeFragment as? WebViewFragment)?.reload()
// Re-select the previous tab
binding.bottomNavigation.selectedItemId = currentTabId
false // Don't actually select this tab
} else {
loadTab(item.itemId)
true
}
}

Why this works: No gesture conflict. Users tap the refresh icon when they need to reload. The refresh button is always visible, always reliable, and doesn't interfere with content scrolling. It's also more discoverable than pull-to-refresh — some users don't know they can pull down.

Production Polish

App Icon

Generate icons for all Android density buckets from your logo:

Density Size Directory
mdpi 48x48 mipmap-mdpi/
hdpi 72x72 mipmap-hdpi/
xhdpi 96x96 mipmap-xhdpi/
xxhdpi 144x144 mipmap-xxhdpi/
xxxhdpi 192x192 mipmap-xxxhdpi/

Use ImageMagick to batch-generate:
bash
for size in 48 72 96 144 192; do
convert logo.png -resize ${size}x${size} icon_${size}.png
done

Splash Theme

Create a splash theme that shows your brand colour immediately on launch:

xml
<style name="Theme.MyApp.Splash" parent="Theme.MaterialComponents.DayNight.NoActionBar">
    <item name="android:windowBackground">@color/forest_green</item>
    <item name="android:statusBarColor">@color/forest_green</item>
</style>

Apply it to the activity in the manifest, then switch to the regular theme in onCreate. The user sees your brand colour instantly while the WebView loads.

Deep Links

Register your domain so clicking links to your app opens the native app instead of the browser:

xml
<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="yourapp.com" />
</intent-filter>

Handle the incoming intent in MainActivity to route the deep link to the correct tab.

Distribution

Build the APK:
```bash
./gradlew assembleDebug

Output: app/build/outputs/apk/debug/app-debug.apk

text

**Upload to your server:**
```bash
scp app-debug.apk deploy@yourserver:~/app/shared/public/app.apk

Create a download page in your Rails app with:
- App icon and name
- Current version number
- "Download" button linking to the APK
- Installation instructions (enable "Unknown sources" / "Install unknown apps")
- QR code for quick mobile access

For signed release builds (required for Play Store, optional for side-loading):
bash
./gradlew assembleRelease

You'll need to configure a signing key in build.gradle — but for internal distribution, debug builds work fine.


The Final App

Component Lines of Code
MainActivity.kt ~80 lines
WebViewFragment.kt ~120 lines
UpdateChecker.kt ~50 lines
Layouts (XML) 2 files
Total Kotlin ~250 lines

The app:
- Opens instantly with branded splash screen
- Five-tab navigation with instant switching (no page reloads)
- Persistent login sessions via cookie management
- Auto-update checking with force-update support
- Camera and microphone access for video calls
- Reliable scrolling (no gesture conflicts)
- External URLs open in system browser
- Deep link support from URLs and notifications
- Direct distribution without Play Store

And the Rails app? Zero changes to serve native. Same views, same controllers, same Turbo and Stimulus. The hotwire_native_app? helper conditionally hides web navigation — that's the only touch point.


When to Use This Pattern

Perfect for:
- School portals, clinic systems, internal business tools
- Apps where the web version is already responsive
- Teams without mobile developers
- Apps needing video/audio in WebView
- Internal distribution (not competing in app stores)

Not ideal for:
- Consumer apps (users expect native animations and performance)
- Offline-first requirements
- Heavy native hardware integration (Bluetooth, NFC, AR)

The Bottom Line

You don't need React Native. You don't need Flutter. You don't need to rewrite anything. If your Rails app works on mobile Chrome, it works in a native Android shell — with bottom tabs, persistent sessions, video calling, and auto-updates.

200 lines of Kotlin. One afternoon. Ship it.


This approach powers the LVHS school management app serving 2,500+ students and 150+ staff. Admin, teacher, student, and parent portals — all from a single Rails 8 codebase, accessible on web and native Android.

Building something similar? Let's talk — I can help you ship your Rails app to Android in a day.

Filed under Ruby on Rails

Discussion

Comments

Leave a comment

No comments yet. Be the first to share your thoughts!

Enjoyed this post?

Subscribe to get notified about new articles.