Dual Rendering Engines
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.
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 has no public JS injection / console callback / request interception APIs, so those features are turned off;
- The Full / Lite build variants are controlled by the
GECKO_ENABLEDcompile flag; the Lite build uses 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
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.
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 first section put into practice.
CodeMirror Integration
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
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.
// 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
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.
touch.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 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.
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 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.