01

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.

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

02

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.

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 first section put into practice.


03

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

04

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.

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.


05

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.

// One-finger drag: move the cursor by relative displacement
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: 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.

06

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.

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


07

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.

Kotlin 2.3 AGP 8.13 minSdk 26 GPL-3.0