Skip to main content
◆ Article

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".

Android Kotlin WebView GeckoView CodeMirror
[ 00 ]

Contents

Jump straight to a section.

  • 8 sections
  • 7 min read
[ 01 ]

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
GeckoView engine version 153
minSdk · API level 26
Lite build size reduction 99%
Per-resource cache limit 20MB
[ 02 ]

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.

kotlin

      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_ENABLED compile 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."

[ 03 ]

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.

kotlin

      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.tsv holding 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 plus renameTo gives an atomic write, with a 20MB per-resource limit;
  • Responses carry the long-cache header Cache-Control: max-age=31536000, immutable so 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.

[ 04 ]

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 __hvTouchSeen every 50ms in onTouchEvent; if the JS takeover fails, HVEditor.scrollBy is 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."

[ 05 ]

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.

kotlin

      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.

[ 06 ]

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.

js

      // 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: scrollByPx walks up the tree to find a scrollable container and changes scrollTop directly (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.
[ 07 ]

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.

kotlin

      @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.

[ 08 ]

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.


Kotlin 2.3 AGP 8.13 minSdk 26 GPL-3.0

Related articles

Other long reads in the same layout: a design-system rework, an interactive comparison and a player embed.
Comments Leave your thoughts
Guide