NW-HTMLviewer Mobile HTML Workbench
An industrial-grade mobile HTML editor and full-screen preview app: CodeMirror 6 offline editing, a dual rendering engine (system WebView / GeckoView), offline resource caching, and full file management — an engineering practice centered on the "boundaries of browser capabilities".
Contents
Jump straight to a section.
If you're interested in the boundaries of WebView capabilities, focus on sections 01 / 02 / 05.
Project Overview
The key readings that appear in the body, collected here first: rendering engine versions, API level, bridge methods and cache limits.
- [*]Renderer interfaceloadHtml · loadFile · reload · executeJs · destroy
- [*]Capability detection propstouchpad · console · resourceCache
- [*]Build variantsFull / Lite (GECKO_ENABLED)
- [*]Binder per-call limit32 KB
- [*]Content chunk sizes32k · 50k · 64k
- [*]Touchpad sensitivity1.6
- [*]Trash undo window5 s
- [*]Cache response headermax-age=31536000, immutable
- [*]Cache file naming rulefirst 24 chars of sha1(url) + extension
- [*]Tech stackKotlin 2.3 · AGP 8.13 · Room · Hilt
- [*]LicenseGPL-3.0
Dual Rendering Engines
Rendering abstracted behind one Renderer interface: probe capabilities first, then degrade — rather than pretending to have them.
The biggest pain point in preview scenarios is inconsistent rendering behavior: the same
HTML can behave completely differently under the system WebView and GeckoView. The project abstracts
rendering behind a Renderer interface that uniformly provides
loadHtml / loadFile / reload / executeJs / destroy, plus a set of
capability detection properties — whether the touchpad, console and resource cache are each
supported — and capabilities that aren't supported degrade outright instead of pretending to exist.
interface Renderer { val view: View fun loadHtml(html: String, baseUrl: String?) fun loadFile(file: File) fun executeJs(script: String) fun reload() fun destroy() // Capability detection: degrade honestly for whatever can't be done val touchpadSupported: Boolean val consoleSupported: Boolean val resourceCacheSupported: Boolean}
- Lite mode (WebViewRenderer): the system Chromium engine with zero extra overhead, offering console collection, request interception and all of
evaluateJavascript; - Compatibility mode (GeckoRenderer): embedded GeckoView 153 with its own engine and fixed rendering behavior, paired with a persistent disk cache; the trade-off is that Gecko exposes no public JS injection / console callback / request interception APIs, so those features are switched off;
- The Full / Lite build variants are controlled by the
GECKO_ENABLEDcompile flag; the Lite build ships only the system WebView and is about 99% smaller.
"A good abstraction doesn't disguise every engine as the same — it declares what each one can and cannot do."
Offline Resource Cache
On first load, http(s) subresources are persisted locally; from then on the URL stays the same and is served straight from disk.
The most frustrating part of editing HTML on mobile is that pages relying on a CDN become unreadable the
moment you go offline. The solution: on first load, download http(s) subresources and
persist them into a hidden .htmlviewer_cache folder next to the HTML file;
from then on the same URL is served locally. The implementation hooks into
shouldInterceptRequest: it first checks the in-memory index with serve, and on a
miss calls download to persist and return the resource.
override fun shouldInterceptRequest( request: WebResourceRequest): WebResourceResponse? { val url = request.url.toString() if (!url.startsWith("http")) return null // On a cache hit, return the local response directly resourceCache.serve(url)?.let { return it } // On a miss: download and persist to .htmlviewer_cache return resourceCache.download(url)}
- The index is an
index.tsvholding one TSV record per line (url / fileName / mime / size / time / hash); an in-memory mirror avoids hitting the disk on every request; - File names take the first 24 characters of
sha1(url)plus the extension; a temp file plusrenameTogives an atomic write, with a 20MB per-resource limit; - Responses carry the long-cache header
Cache-Control: max-age=31536000, immutableso the WebView itself joins in on cache hits.
The Gecko engine has no request interception API, so this feature is WebView-only — exactly the "capability detection" from the previous section put into practice.
CodeMirror Integration
An offline IIFE bundle plus cross-process communication over HVBridge, and a string of pitfalls under the Binder limit.
The editor itself is CodeMirror 6, bundled at build time with esbuild into an
offline IIFE bundle placed in assets; at runtime a separate WebView loads
editor.html, and the Android side talks to the page through HVBridge, a
JavascriptInterface. There were more pitfalls along the way than expected:
- Binder 32KB limit: a single cross-process call has a size cap, so content is pulled and written back in 32k / 50k / 64k chunks (
saveChunk / getContentChunk); - EditContext switch: the EditContext in Chromium ≥ 126 breaks touch and scrolling on the Android side, so you must set
EditorView.EDIT_CONTEXT = false; - One-click formatting: prettier standalone picks a parser by file extension (html / css / babel), and plugins load dynamically only on first use;
- Double safeguard for scrolling: JS takes over scrolling via touchmove, while the Kotlin side checks
__hvTouchSeenevery 50ms inonTouchEvent; if the JS takeover fails,HVEditor.scrollByis injected as a fallback; - Syntax highlighting turns off automatically beyond 1 million characters to preserve typing performance.
"Running the editor inside a WebView isn't hard; the hard part is keeping it stable against the Binder limit, the EditContext upheaval and touch failures."
Encoding Compatibility
Detection is BOM-first; with no BOM, strict decoding is what teases out the real encoding.
A large share of legacy HTML is GBK. Detection is BOM-first (UTF-8 / UTF-16LE / UTF-16BE); without a BOM it tries "strict UTF-8 decoding" first, then falls back to strict decoding with GB18030 (a GBK superset), and finally to plain UTF-8 if that still fails. The detection result is persisted with the file metadata, editing and saving keep the original encoding, and GBK files can be converted to UTF-8 in one click.
fun detect(bytes: ByteArray): TextEncoding { // BOM first if (bytes.startsWith(UTF8_BOM)) return UTF_8 if (bytes.startsWith(UTF16LE_BOM)) return UTF_16LE if (bytes.startsWith(UTF16BE_BOM)) return UTF_16BE // Strict UTF-8 decoding failed, so try GB18030 (a GBK superset) if (canDecode(bytes, Charsets.UTF_8)) return UTF_8 if (canDecode(bytes, GB18030)) return GBK return UTF_8 // fallback}
Note that strict decoding requires CodingErrorAction.REPORT — the default REPLACE quietly
substitutes illegal bytes with "?", letting mojibake slip through as normal text.
Simulated Mouse
Injection compensation for synthetic events: default behaviors and pseudo-classes both have to be patched back in by hand.
Preview mode supports a touchpad-style simulated mouse: an arrow cursor appears on screen; a
one-finger drag moves the cursor by relative displacement (sensitivity 1.6), a light tap acts as a click, and
a two-finger vertical swipe scrolls the page. Because everything is synthetic events injected via JS, the
browser won't automatically trigger CSS :hover / :active or run any default
behavior — so highlight states must be applied manually, and default behaviors like a
navigation, checkbox toggling and input focusing have to be patched in by hand too.
// One-finger drag: move the cursor by relative displacementtouch.addEventListener('move', function (e) { const dx = (e.dx * 1.6) | 0; const dy = (e.dy * 1.6) | 0; moveCursor(dx, dy); // Synthetic events don't trigger :hover; apply the highlight manually and dispatch Mouse + Pointer events const el = document.elementFromPoint(cx, cy); hover(el); dispatchMouse(el, 'mousemove');});
- Two-finger scrolling:
scrollByPxwalks up the tree to find a scrollable container and changesscrollTopdirectly (synthetic wheel events don't trigger default scrolling); - Gesture takeover: capture-phase listeners plus
touch-action: none; - The injected script ships with full teardown via
__HV_TP_CLEANUP__; it re-injects automatically after page navigation, retrying up to 5 times if injection fails.
File Management & Trash
File contents and metadata kept separate; deletion goes through a same-partition rename, buying a 5-second undo.
File contents and metadata are kept separate: files live in the app-specific directory (no storage
permission needed), while metadata such as favorites, recent opens and remembered encodings is stored in
Room. The file_meta table uses the absolute path as its primary key and keeps stats like
line/character counts, merged into the UI in real time during list scans.
@Entity(tableName = "file_meta")data class FileMetaEntity( @PrimaryKey val path: String, // absolute path as primary key val isFavorite: Boolean, // whether favorited val groupId: Long?, // favorite group val lastOpenedAt: Long?, // last opened val encoding: String, // UTF-8 / GBK / UTF-16LE val lineCount: Int, val charCount: Int, val createdAt: Long)
Deletion is undoable for 5 seconds: files aren't deleted outright but renameTo'd
into .htmlviewer-trash on the same mount point — rename sits on rename(2), which
fails with EXDEV across file systems, so the trash must live on the same partition as the app's root
directory. The undo stack uses an ArrayDeque of (trashFile, originalPath) pairs, a
Snackbar provides the "Undo" entry, entries are cleaned up automatically when the 5-second TTL expires, and
any leftovers from the previous session are cleared at process startup.
Closing Thoughts
The choices and the convergence that remain once the "boundaries of browser capabilities" are treated as a first-class citizen.
The biggest takeaway from this project is treating the "boundaries of browser capabilities" as a first-class design concern: different engines, API availability, the Binder limit, encoding differences — every item is detected first, then degraded, and finally given a fallback. On the technology side, Jetpack Compose + Material 3 carry the UI, MVVM + Repository + Hilt carry the architecture, Room + DataStore manage the data, and CodeMirror 6 with the dual engines handles rendering — decoupled layer by layer, each piece swappable.