Skip to content

Repository files navigation

Seed Note

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.

Paste markdown — headers, lists and tables become real blocks Select table cells, cut, and paste them back as a table Organise notes by drag and drop in the grid

Capture — pasted markdown becomes real blocks  ·  Edit — a cut cell selection pastes back as a table  ·  Organize — drag-and-drop grid

What the editor actually does

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

Status

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

Explorer grid, light theme Explorer grid, dark theme A note with a table block Korean localisation

Five things worth reading the code for

This is a single-author project, and these are the parts that were expensive to get right.

1. A vendored fork of Compose Foundation

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.

2. Every selection gesture is hand-built

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.

3. Files are the source of truth; the database is a derived index

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.

4. Interaction logic is extracted into pure Kotlin

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.

5. Tests that run on a bare clone

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

Architecture

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.

Build

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.

What is not in this repository

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.

Built with

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.

License

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.

About

Structured block editor for Android — paste markdown and get real blocks. Offline only, no account, no network permission; notes are plain JSON files on disk.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages