← Back to projects

work · active

How I Turned One-Off WebViews into a Platform for Teachmint's AI Tutor

Running Teachmint's web products inside the Android app without blank screens: a bridge that waits until the page is ready, web screens that stay alive between pages, and errors that say what went wrong.

Android · WebView · PlatformKotlin · Android WebView · Jetpack Compose · JavaScript bridge

Role
Owner, WebView integration
Duration
May 2025 – Jan 2026
Type
work
Status
active

Key results

  • 3Web screens kept alive between pages, instead of reloading on every screen
  • 5Named error types, so a blank screen tells us why

Overview

Teachmint’s Android app ships several features that are really web apps. The AI tutor and EduBits are both web apps loaded into the app by URL. From May 2025 to January 2026 I owned how the Android app hosts them: how they’re created, how they talk to native code, how they survive navigation, and what happens when they fail.

It started as one WebView behind a deep link. EduBits and the tutor grew their own managers, each with a JavaScript bridge, a message queue and named failure codes. In January 2026 I pulled those patterns into a generic layer, with a factory, an instance manager with an LRU cache and a ManagedWebView, for the newer flows: paywalls and transcripts.

The problem

Embedding a web product sounds like webView.loadUrl(url). In practice:

  • The page isn’t ready when native code is. A deep link or notification arrives, native code wants to tell the page where to navigate, and the page’s JavaScript hasn’t loaded yet.
  • Navigation destroys state. A WebView tied to a fragment is rebuilt every time the user comes back. That means a cold page load, a lost scroll position and a re-authenticated session.
  • Native features leak in. Fullscreen video, file pickers, the camera and the microphone are all callbacks that need an Activity.
  • The WebView itself can be missing. WebView is a separate, updatable system package, and on some devices it’s disabled or absent.

My approach

Native screen ──► WebViewInstanceManager ──► ManagedWebView ──► WebView
                  (LRU, 3 cached)            (bridge state,     (created by
                                              message queue)     WebViewFactory)
                                                  ▲   │
                         page JS ── bridgeReady ──┘   └── receiveDataFromAndroid(json)
  1. One factory. In the managed layer, every WebView is created the same way, with the same settings, cookie policy, bridge and clients.
  2. A queue until the bridge is ready. Native code never calls into the page directly. Messages go through sendDataToWebView, which queues them until the page calls back to say its bridge is ready, then flushes them in order.
  3. WebViews keyed and cached. A LinkedHashMap in access order evicts the least recently used instance once more than three are alive. Navigating back to a feature reuses its WebView, with the page already loaded. Temporary WebViews are explicitly uncached and owned by the caller.
  4. Reuse the bridge, refresh its context. When a cached WebView moves to a new fragment, the existing JS interface is updated with the new screen’s context and callbacks, rather than a new one being created per fragment.
// LRU of live WebViews (access order), destroying whatever falls out.
private val webViews =
    object : LinkedHashMap<String, ManagedWebView>(4, 0.75f, true) {
        override fun removeEldestEntry(
            eldest: MutableMap.MutableEntry<String, ManagedWebView>?,
        ): Boolean {
            val evict = size > maxCachedWebViews
            if (evict) eldest?.value?.destroy()
            return evict
        }
    }

Important decisions

Warming up in the Application class, and taking it back

Opening EduBits cold meant starting Chromium, then loading the page. So in December 2025 I tried the obvious optimisation: create and warm the WebView in Application.onCreate, on phones only, before any screen asks for it.

It lasted a day. A WebView created in Application has no Activity, so the manager’s activity had to become nullable, and with it every callback that needs one:

  • fullscreen video (onShowCustomView)
  • new windows (onCreateWindow)
  • the file chooser
  • camera capture through startActivityForResult

Each became a null check and a silent failure path. The next commit made the Activity non-null again and removed the warm-up. The cost of a cold page load was real, but so was every feature that quietly stopped working when its Activity wasn’t there. Later, the managed layer took a different approach: keep loaded WebViews alive across navigation.

Treat a missing WebView as a state, not a crash

WebView(context) can throw when the WebView provider is disabled or missing. Before creating one, the app now checks that WebView.getCurrentWebViewPackage() returns a provider and that the package actually resolves. If not, the screen shows a fallback with a way to install it, instead of crashing:

fun isWebViewPackageAvailable(context: Context): Boolean {
    val provider = WebView.getCurrentWebViewPackage() ?: return false
    return try {
        context.packageManager.getPackageInfo(provider.packageName, 0); true
    } catch (e: PackageManager.NameNotFoundException) { false }
}

Give every failure a name

A blank WebView says nothing. The EduBits and tutor screens now report five numbered failure codes to analytics: no WebView, load failed, timeout, initialisation failed, and recreation after the hosting Activity changed. A support ticket that says “EduBits is blank” can be matched to one of them.

What went wrong

A quote broke the bridge. Native code sent JSON to the page by building a JavaScript call as a string, receiveDataFromAndroid('…'), with the JSON pasted between single quotes. Any value with an apostrophe ended the string early, and the page received a syntax error instead of data. The fix replaced hand-escaping with JSONObject.quote(json), which produces a valid JavaScript string literal for any input.

Software rendering fixed one thing and broke video. A stats WebView was switched to a software layer to work around a rendering problem. Two months later it was reverted “due to video playing issue”. Software layers also don’t draw accelerated canvases, which charts and video both depend on. It’s a lever to pull only with a test for every kind of content the page shows.

Permission requests can only be answered once. The page asks for the microphone or camera through onPermissionRequest, and native code grants whatever the app’s runtime permissions allow. Answering a request that’s already been handled throws IllegalStateException. The app crashed on that until the grant and deny paths were guarded.

Leaks and stale bindings. A cached WebView outlives the fragment that showed it. The fragment’s onDestroyView must clear the WebView’s callbacks and drop the view binding, or the cache keeps every fragment it ever touched alive.

Result

I wrote most of the app’s Edu WebView code. The tutor and EduBits run on their managers, with a bridge-ready queue, a missing-WebView check and named failure codes. Paywalls and transcripts run on the managed layer’s factory, bridge and LRU cache. Moving the older managers onto it is the natural next step.

What I’d still change: the newer ManagedWebView sends its messages with the old single-quoted pattern, without the escaping fix the other screens use. I found it while writing this up. It’s the same bug, waiting for its first apostrophe. The fix is one line, the same JSONObject.quote, and it belongs in the factory so no screen can get it wrong.

What I learned

  1. The page is a separate process with its own startup. Anything native wants to say must wait for an explicit “ready” from the page. Timeouts and guesses don’t work.
  2. WebView needs an Activity for more than you think. Creating one early buys load time and costs every feature that needs a window.
  3. Never build JavaScript by concatenating strings. Let a JSON encoder produce the literal, and put it in one place.
  4. Caching WebViews moves the lifecycle problem. You stop paying for reloads and start owning leaks, stale bridges and eviction.
  5. Name your failures. “Blank screen” becomes five actionable codes once each path reports itself.