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.
- Owner, WebView integration
- May 2025 – Jan 2026
- work
- 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)
- One factory. In the managed layer, every WebView is created the same way, with the same settings, cookie policy, bridge and clients.
- 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. - WebViews keyed and cached. A
LinkedHashMapin 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. - 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
- 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.
- WebView needs an
Activityfor more than you think. Creating one early buys load time and costs every feature that needs a window. - Never build JavaScript by concatenating strings. Let a JSON encoder produce the literal, and put it in one place.
- Caching WebViews moves the lifecycle problem. You stop paying for reloads and start owning leaks, stale bridges and eviction.
- Name your failures. “Blank screen” becomes five actionable codes once each path reports itself.