A Capacitor shell that runs the ZenNotes product core (packages/app-core from
the zennotes monorepo) inside the Android System
WebView, backed by a local-first vault on the device filesystem. Implements the
architecture in docs/specs/mobile/ (the Phase 2 "Android fast-follow"),
derived from the iPhone shell at ../zennotesiphone — the two shells share the
same structure and bridge modules; platform-specific divergences are noted
below.
The zennotes repo is consumed read-only, straight from source, via Vite/TS
path aliases (see vite.config.ts) — nothing in that repo is modified. The
repo is expected at ../../opensource/zennotes relative to this directory.
src/
main.tsx install bridge → open vault → renderZenNotesApp()
bridge/
mobile-bridge.ts the mobile ZenBridge (window.zen) implementation
vault-fs.ts desktop vault.ts semantics over Capacitor Filesystem
vault-core.ts pure helpers ported 1:1 (folder map, meta extraction,
naming, search scoring) — keep in sync with desktop
native-fs.ts Capacitor Filesystem wrapper (vault root = app-scoped
external storage via Directory.External)
events.ts VaultChangeEvent emitter (in-app writes + rescan)
ui-mobile/
MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette),
phone drawer behavior via the shared Zustand store
mobile.css safe areas, overlay drawers, keyboard handling
android/ Capacitor-generated Gradle project (appId md.zennotes)
app/src/main/java/md/zennotes/
MainActivity.java registers ShareInboxPlugin, stashes ACTION_SEND shares
ShareInboxPlugin.java Android ShareInbox (same jsName/contract as iOS)
Key decisions (all forced by "don't modify the zennotes repo"):
runtime: 'web'— the bridge contract has no'mobile'runtime yet. Every desktop-only affordance in app-core gates onruntime === 'desktop', so'web'+ the capability flags produces correct mobile behavior. When the contract gains'mobile'+ the new capability flags (spec 02), flip it here.platform: 'linux'(iOS shell reports'darwin') — gives app-core Ctrl-based keymaps and hides Mac-only chrome; right for Android hardware keyboards.- Vault location — app-scoped external storage:
/Android/data/md.zennotes/files/ZenNotes/<vault>(Directory.External), the spec-03 Android default tier: no permission prompt, works under scoped storage. Do not switch toDirectory.Documents— on Android that is the public Documents collection, which the Filesystem plugin permission-gates and Android 11+ scoped storage effectively breaks. - Durable app preferences. WebView localStorage is evictable on some
devices, which silently reset theme and editor settings.
src/bootstrap.tsmirrorszen:prefs:v2into native Capacitor Preferences on every write, restores it before app-core loads (the dynamicimport('./main')preserves the module-eval ordering the inline index.html seed script relies on), and re-applies the theme attributes pre-paint. - Two storage tiers. Default: app-scoped storage (above) — with a
one-time boot probe (
initVaultsRoot) that falls back to internal app storage (Directory.Data) on devices wheregetExternalFilesDiris unusable (custom ROMs / restricted profiles crashed at first launch with "Missing parent directory", issue #2); the chosen root is persisted so it never flips between launches. Advanced: the SAF external-folder tier (spec 03) — "Choose Folder…" in the New Vault sheet opensACTION_OPEN_DOCUMENT_TREE(FolderPickerPlugin.java; the persisted tree-URI permission is the "bookmark", surviving reboots), and every file op on acontent://root routes throughSafFsPlugin.java(DocumentsContractchild queries with a documentId cache — one IPC per directory listing, NOT per-file DocumentFile resolution) because Capacitor Filesystem cannot address tree URIs. This is the tier that enables Syncthing/FolderSync cross-device workflows. iCloud stays iOS-only;icloud.tsis kept (inert) to minimize drift against the iPhone shell. Unlike the iPhone shell's single-bookmark slot,folder-picker.tskeeps a registry of every picked folder (zn-mobile:external-vaults, with the legacy single-ref key as the "current" pointer and migration seed) so any number of SAF folder vaults stay switchable (zennotes#584) — external-tier root tokens carry the bookmark URI (zn://external-vaults/<encoded-uri>). Porting this back to iOS is a known follow-up; multiple security-scoped bookmarks are equally legal there. - SAF performance (measured, Pixel 7 AVD, 500-note vault): ~2 ms/op on
app storage vs ~35 ms/op over SAF (~17×). Cold open+index: ~3.3 s local vs
~20 s SAF; warm relaunch: ~2.6 s vs ~23 s — dominated by a per-launch
full-body read pass (workspace/tasks restore) that every tier performs but
only SAF makes expensive. The note-meta cache itself hits correctly on both
tiers. Verdict: fine for small/medium vaults; for large vaults the next
lever is batching (
readTextMany-style plugin call or a native scan op), notMANAGE_EXTERNAL_STORAGE— revisit per spec 09 if users hit it. - On-disk contract is byte-compatible with desktop: same folder layout
(
inbox|quick|archive|trash,assets/, legacyattachements/recognized — the misspelling is intentional and load-bearing), same.zennotes/metadata (vault.json, workspace.json, comments/), same naming/collision rules, same NoteMeta extraction regexes,systemFolderPathsremaps honored via@shared/system-folder-paths. - Share sheet → quick capture: Android needs no app extension — a
text/plainACTION_SENDintent-filter on MainActivity stashes captures in SharedPreferences; the app-localShareInboxplugin (samejsNameanddrain()contract as the Swift one) hands them to the unchanged JS side on launch/foreground. - Long-press context menus: unlike WKWebView, the Android WebView fires a
real
contextmenuevent on long-press, so the iOS 450ms synthesizer is replaced by a passive listener that only adds the haptic and swallows the post-lift synthetic mouse burst (without which menus close instantly). Inside the editor the listener instead STOPS the event (issue #8): both shells keep text selection native there, but on Android the real contextmenu would otherwise reach app-core's editor menu, which preventDefaults Chromium's selection action bar and opens at the finger — directly over the selection. Stopping propagation (never preventDefault) restores the native handles + action bar, including the events Chromium refires on selection-handle lifts and text-handle menu taps (those reportpointerType: 'mouse', so there is deliberately no mouse carve-out). - Editing-toolbar keyboard anchor (issue #7): the iPhone shell anchors
the formatting toolbar to a keyboard-top Y computed from
keyboardWillShow(--zn-kb-top) to dodge a WKWebView stale-paint quirk. On Android that geometry is wrong twice over — the plugin'skeyboardHeightincludes the gesture-nav inset the edge-to-edge WebView margin already excludes, and the plugin event races the JSresizeevent (toolbar stuck mid-screen). Here the toolbar is viewport-bottom anchored (bottom: 0on the natively-resized viewport; lifted by--zn-kb-heightunder thezn-kb-noresizetablet mode instead of a media query, so landscape phones keep the docked anchor). The toolbar's dismiss button also blurs the editor beforeKeyboard.hide()— Android's hide ishideSoftInputFromWindowonly (noendEditing-style focus resign like iOS), and a still-focused editor re-summons the keyboard. - Folders/tags discoverability pass (Discord feedback, 2026-08-14): inline "New folder" row at every drawer level (also the only way to create NESTED folders on a phone), note long-press → Move to…/Delete action sheet in the drawer, a teaching Tags empty state (DOM patch — app-core is read-only), and an "Organize with tags and folders" welcome-note section whose inline #ideas seeds the Tags view. This pass is kept in parity with the iPhone shell.
- TikZ capability-gated off; workflows not offered; custom TextMate languages gated off; vim mode defaults off on first run — all as on iOS.
- Desktop 2.21/2.22 features arrive via shared source, same as the iOS
1.4 build (both stamp upstream 2.22.0): in-progress task state (
- [/], set via long-press → Mark in progress — the long-press allowlist covers task rows, kanban cards, and calendar day cells), subtask rollups, archived notes retiring their tasks, inline mermaid while writing, text replacements, configurable tab size, manual kanban card order, and absence-aware remote reads (@shared/remote-absence).
npm install
npm run sync # vite build + cap sync android
npx cap open android # open in Android Studio, or:
cd android && JAVA_HOME=/opt/homebrew/opt/openjdk@21 \
ANDROID_HOME=/opt/homebrew/share/android-commandlinetools \
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkToolchain: JDK 21 + Android SDK 36 (Capacitor 8; targetSdk 36 required by
Play from Aug 2026, minSdk 24 — Android 7.0+). android/local.properties
points at the SDK. Dev loop against a browser (no emulator): npm run dev —
Capacitor plugins are absent in a plain browser, so vault I/O won't work; use
the emulator for real testing.
npm run upstream reports what changed in the zennotes repo since the
.zennotes-commit stamp and typechecks the bridge against current source.
Prefs seeding + theme attributes live in an inline classic <script> in
index.html, not a module: the app-core store reads
localStorage['zen:prefs:v2'] at module-evaluation time, and Rollup chunk
hoisting (manualChunks) runs the store chunk before any entry-chunk module —
an imported "bootstrap.ts" silently ran too late on fresh installs.
There is deliberately no manualChunks rule for mermaid/cytoscape/dagre
(upstream 2.20 finding): naming that chunk hoisted it into the entry's static
graph, so every cold start fetched and evaluated ~2.5MB of diagram code (plus
vendor-markdown, which it imports) before a note was even open. Left to
Rollup, mermaid splits into async per-diagram chunks fetched the first time a
diagram renders. Same idea: /@xyflow/ is excluded from the vendor-react
substring match so React Flow stays inside the never-loaded WorkflowsView
chunk. Check dist/index.html's modulepreloads after touching the config —
the entry must not statically import mermaid, markdown, or highlight chunks.
On-device and SAF vaults can connect to the optional ZenNotes Cloud service from Settings → Cloud. The mobile bridge stores the account token in Android secure storage, links or creates a cloud vault, runs the shared offline-first sync engine, and exposes backups, note-level restore, publishing, and automatic sync on app foreground and local changes. Local vaults, SAF folders, and self-hosted workspaces continue to work without an account or subscription.
- SAF read batching — a
readTextMany/native-scan plugin op to make large external vaults fast (see the performance note above) - Quick-capture home-screen widget / app shortcuts
- Store distribution work (signing config, Play listing — spec 08)