A structured block editor for Android — the Evernote/Notion class of editor, not a plain-text notepad. A note is a sequence of typed blocks you can convert, nest, reorder, and select across; tables hold real cells, code blocks hold real code, and pasted markdown becomes actual structure rather than a wall of text. Built to stay native and light, which is where it parts ways with its reference class. Offline only, no account, no network permission.
Built with Kotlin and Jetpack Compose. Notes are plain JSON files on disk; the database is a cache that can be thrown away.
Capture — pasted markdown becomes real blocks · Edit — a cut cell selection pastes back as a table · Organize — drag-and-drop grid
| Block | Behaviour |
|---|---|
| Paragraph | Inline bold spans, stored as offset ranges and persisted with the note |
| Heading | Three levels (H1–H3) |
| List | Bulleted, numbered, and checkboxes — nestable, with the marker style following depth |
| Table | Real cells: edit in place, add/remove rows and columns, select a rectangle of cells |
| Code | Fenced block holding multi-line text verbatim, whitespace significant |
| Divider | Horizontal rule |
Every block type carries the same indentation level, so nesting is uniform rather than per-type. Blocks convert in place (a paragraph becomes a heading or a list item without retyping), reorder by drag, and selection spans across block boundaries — including a rectangular cell range inside a table, which copies and pastes back as a table.
Pasting is a first-class path, not a convenience: a markdown document arriving from anywhere — an AI answer, a web page, another editor — is parsed into headings, lists, tables, code fences and dividers instead of landing as one text lump. Every edit above is one undo step, and selection state is restored with it.
Search runs on SQLite FTS4 (unicode61 tokenizer) over text derived from the
blocks, in an index rebuilt from the note files on every launch. Queries are prefix
matches against whole tokens, so kim finds kimchi — and, in Korean, 김치 finds
the word 김치파스타. What it does not do is match inside a word: nothing finds
김치 within 맛있는김치, because there is no n-gram index. That limitation is
characterized by a test rather than left to be discovered
(FtsSearchTest).
Pre-store release. Current version 0.9.x, minSdk 26 / targetSdk 35.
Localised in 7 languages (English fallback, plus Korean, Japanese, Simplified
Chinese, Spanish, French, German).
This is a single-author project, and these are the parts that were expensive to get right.
The editor does not use BasicTextField's built-in selection UI. It cannot: the
intrinsic cursor handle lives in a separate PopupWindow, so it intercepts
teardrop drags at the WindowManager level before any consumer-level gesture
detector sees them. The intrinsic magnifier and the intrinsic
selectionGestureInput producer cause the same class of conflict — the latter
would grab focus on long-press, write a selection directly, and consume the
whole MOVE stream, starving the editor's own detectors.
So androidx.compose.foundation:foundation-android:1.8.2 is rebuilt from source
with three added CompositionLocals — LocalDisableCursorHandle,
LocalDisableTextFieldMagnifier, LocalDisableSelectionGestures — and
published locally as 1.8.2-seed-patch1. The editor subtree provides true for
all three and owns every selection gesture itself.
- Sources of the patched files:
foundation-patch/src/(Apache-2.0, headers intact) - Protocol, rationale, and the BOM-bump re-apply checklist:
docs/foundation-fork.md - The prebuilt AAR is committed under
repo/local-maven/, so a clone builds without running the patch pipeline.
Disabling the platform's selection UI (above) means the editor owes the user everything it took away — and text selection is the interaction people are least willing to forgive. So the whole vocabulary is implemented here, over raw pointer events:
- Long-press a word to select it, then keep the finger down to extend — the press and the drag are one continuous gesture, not select-then-grab-a-handle. Long-pressing a different word while a selection is live replaces it and enters extend in the same motion.
- A custom caret teardrop: drag it to move the caret across blocks; hold it still to open the paste menu. Its upper half belongs to the text, its lower half to the handle — one pointer stream, two owners, resolved on the DOWN event.
- Range handles that survive scrolling, flip their anchor when dragged past each other, and place a floating toolbar that avoids covering what it acts on.
- Rectangular cell selection inside tables: long-press a cell, drag to sweep a rectangle, or grab a corner handle to resize it. The rectangle copies and pastes back as a table.
- Whole-block selection and drag-reorder inside a note, plus card drag in the grid with nesting, autoscroll, and mid-drag page switching.
The hard part is not any one of these; it is that they share one pointer stream
and must never both fire. A plain vertical drag has to scroll even while a
selection is live; a long-press that then moves has to become an extend, while a
long-press that stays still has to become a menu; a press inside a table cell
belongs to the cell, unless it started on a handle. Those decisions run through a
state machine with mutually exclusive states — Idle, Editing,
Selection.Range, Selection.Block, Selection.Cell, Dragging — and a rule
the whole codebase is held to: one gesture, one producer. The geometry and the
transition rules are pure functions (see the next section), so the parts that
would otherwise only be testable by touching a screen are covered by JVM tests.
One JSON file per node. Directories mirror the note tree: projects, pages, and
folders are directories carrying a _project.json / _page.json /
_folder.json payload plus an advisory .order file. Promoting a note to a
folder is a single directory rename. Deletion moves the file into .trash/ with
a JSONL provenance index.
Room holds no authoritative state. On every launch the tree is scanned from disk
and the index — including the FTS table used for search — is rebuilt. The
practical consequence: edit a note file with adb push, git, or a sync client,
relaunch, and the change is simply there. Writes are atomic
(temp → fsync → rename) and ordered so that a crash mid-write cannot orphan a
node.
Relevant code: data/nodestore/ (FileNodeStore, NodeFileFormat,
NoteTreeStore), data/index/FileTreeIndexer.
Everything that can be decided without a frame, a Context, or a coroutine dispatcher lives in pure classes with no Compose imports — which is why they are covered by JVM tests rather than instrumentation tests, and why they are the portable surface if this ever targets a second platform:
| Concern | Pure logic |
|---|---|
| Paste / block splicing | PasteLogic, StructuralPasteLogic, ImePasteDiff |
| IME newline classification | EnterEchoPolicy, SoftBreakPolicy |
| Selection and drag geometry | EditorSelectionLogic, SelectionDropValidator |
| Explorer drag-and-drop | SlotGeometry, TabDropGeometry, BlockDragLogic |
| Toolbar placement | ToolbarPlacement |
| Markdown ingestion | MarkdownBlockParser |
The IME work is the least obvious of these. An Android soft keyboard delivers a
newline through onValueChange in several indistinguishable shapes — a real
Enter, a duplicate echo of one already handled, a re-delivery of a stale editor
buffer on refocus, a multi-line paste, and a selection-replacing paste — and
each needs a different response. EnterEchoPolicy is the pure classifier that
decides which one arrived.
713 JVM unit tests, no device or emulator required:
./gradlew test
They cover the pure logic above, including regression cases pinned to specific bugs. Beyond them the project uses a device-level scenario guard suite — YAML scenarios replayed against a running app, asserted against logcat — which locks the riskiest selection and drag-and-drop transitions. That harness is not part of this repository (see below).
docs/ARCHITECTURE.md is the orientation document: one
diagram per axis — module layers, editor subsystems, the interaction state
machine, the per-frame render pipeline, and the selection authority model.
The last of those is the crux of the editor. Three selection authorities coexist
— the render/gesture state (A-GS), BasicTextField's own IME-coupled
selection (A-TS), and the ViewModel state machine that owns clipboard and undo
(A-MS) — reconciled by a small set of deliberately asymmetric bridges, one per
direction, each documented with its firing condition. Uncoordinated writes
across those three were the root cause of a whole family of selection bugs, and
the bridge table exists so that a change cannot quietly add a fourth writer.
Requirements: JDK 17 or newer (Gradle/AGP requirement; the project itself targets Java 11 bytecode) and Android SDK platform 35.
git clone <this repo>
cd seed-note
./gradlew assembleDebug # gradlew.bat on Windows
No extra setup: the patched Compose Foundation AAR is committed under
repo/local-maven/ and resolved by a local Maven repository declared in
settings.gradle.kts.
To rebuild that AAR from source instead of trusting the committed binary, run
foundation-patch/build.ps1 (PowerShell, Windows). Its inputs — the upstream
1.8.2 artifacts and their SHA-256 proofs — are committed under
foundation-patch/upstream/.
assembleRelease requires your own signing configuration; the signing wiring is
guarded, so a clone without a keystore still configures and builds.
Deliberately withheld, and separately licensed:
- The AI-assisted development harness — agent skills, the accumulated architecture and Compose trap knowledge base, and the workflow commands used to drive it.
- The device automation and scenario guard runner (MCP tooling, YAML scenario suite, logcat assertion harness).
- Demo and store assets, and release signing material.
Everything needed to build and run the app is here; what is missing is tooling
around it. Comments in the source cite that knowledge base by section —
policy-selection-and-drag §6.15, policy-editor-structure-engine §2.7 and
similar — and cite archived per-goal design records by goal name. Those
documents are not published; the citations are kept because the invariant they
name is the reason the code is written the way it is.
Seed Note was scaffolded with Scaff — a
lightweight, markdown-based AI development harness. Every iteration — the
editor's selection engine, defect sweeps, demo assets, release prep — was
tracked as plain-file GOAL.md / DESIGN.md / CONTEXT.md documents with
per-goal archives, so the project's decision history lives in reviewable
markdown rather than tool state.
Copyright (C) 2026 san-tekart
Seed Note is free software under the GNU General Public License, version 3 only — see LICENSE. You may use, study, modify, and redistribute it, provided derivative works are released under the same license with source available.
foundation-patch/ contains modified AndroidX code under the Apache License
2.0, not the GPL — see foundation-patch/LICENSE
and NOTICE for the attribution and the statement of changes.
Pull requests are not accepted; issues are welcome. See CONTRIBUTING.md for why.






