A clean, Google Keep–styled Android client for Nextcloud Notes.
Canonical source: git.henrydowd.dev/henry/nextkeep. The GitHub repo is a mirror.
Offline-first: all reads come from a local Room database; edits are saved locally immediately and synced to the server in the background. Works fully offline and pushes queued changes on the next sync.
NextKeep was written end-to-end by Claude (Anthropic) running in Claude Code. The architecture, the Kotlin/Jetpack-Compose implementation, the offline-first sync engine, the dependency-free markdown engine, the QR login, the unit tests, the release-signing setup, and this README were all generated by the model. A human gave the product direction in plain English ("a clean Keep-styled Nextcloud Notes client", "add QR login", "render markdown in the list", …) and reviewed the result; the model made the design decisions and wrote every line.
It was also developed the way a person would, not just emitted in one shot:
- Verified on-device. The model installed an Android emulator, built and installed the app, and drove the real flows — login, sync, editing, markdown rendering, the QR screen — taking screenshots to check its own work.
- Caught and fixed a real bug that way. Tapping a formatting button on an empty line did nothing (a vacuous-truth bug in the list-prefix toggle); it was found by using the app on the emulator, fixed, and covered with a regression test.
- Tested. Pure logic (content↔title/body mapping, markdown transforms, QR parsing)
is covered by JVM unit tests (
./gradlew :app:testDebugUnitTest).
As with any AI-generated code: it builds, the core flows are verified, and the design is documented below — but review it before trusting it with data you care about, and read the Known limitations section.
- Keep-style UI — staggered two-column note grid, pill search bar, Material 3 with dynamic color (Material You) on Android 12+, light/dark theme, and a themed (monochrome) launcher icon that tints to the system palette on Android 13+.
- Pinned notes — mapped to Nextcloud favorites, shown in a "Pinned" section.
- Labels — Nextcloud categories shown as filter chips; notes are tinted with a Keep-style pastel per category.
- Markdown — Notes are markdown (as Nextcloud stores them). The editor has a
formatting toolbar (headings, bold, italic, bullet/numbered/checklist, quote,
indent/outdent) and an edit⇄preview toggle. List cards and the editor preview
render markdown; checkboxes are tappable (toggle directly on a card or in
preview) and pressing Enter auto-continues lists. See
markdown/(MarkdownEditing,parseMarkdownBlocks,MarkdownText) — dependency-free, unit-tested. - QR login — "Scan login QR code" reads Nextcloud's
nc://login/...code (server + user + app password) and connects, via CameraX + ZXing (offline, no Google Play dependency). Seeqr/. - Editor — title + body with autosave (debounced 400 ms), pin, share, delete (with an Undo snackbar), label editing, "Edited x ago" footer.
- Settings — theme (System/Light/Dark/AMOLED black), font size, independent heading size, grid columns (1–3), note-card preview length, and sort order. Theme and font scale apply app-wide via the Material 3 typography.
- In-app updates — distributed outside any app store, NextKeep updates itself:
Settings → Check for updates fetches the latest signed release from GitHub,
and downloads + installs it in place. See Versioning and releases below and
data/Updater.kt. - Share into NextKeep — share text from any app to create a note.
- App lock — optional biometric / device-credential lock (
BiometricPrompt), re-locking when the app is backgrounded. - Sync — pull-to-refresh two-way sync; local-pending changes win and are pushed
first, deletes use tombstones (with an Undo window before they're pushed), notes
deleted on the server while edited locally are recreated so no edits are lost.
Unchanged notes are skipped on pull (collection ETag → 304, plus per-note etag/
modified checks). Edits use
If-Match; a 412 conflict keeps both versions (the server's, plus the local edit as a "(conflict)" copy). A WorkManager job syncs periodically in the background.
- In Nextcloud: Settings → Security → Devices & sessions → Create new app password.
- In NextKeep: enter the server address (e.g.
cloud.example.com), your username, and the app password — or tap Scan login QR code and scan a Nextcloud login QR code to fill and connect in one step.
The Notes app must be installed on the server (API v1). Credentials are encrypted
with an Android Keystore (AES-GCM) key before being written to app-private DataStore
(see CryptoManager).
Requirements: JDK 17–21 (newer JDKs such as 25/26 are rejected by AGP 8.7) and the Android SDK (platform 35, build-tools 35.0.0). Neither path is committed — both are machine-specific, so set them once per machine:
- JDK — if your system
javais already 17–21, you're done. If it's newer (e.g. JDK 26), don't edit the committedgradle.properties; point Gradle's daemon at a 17–21 JDK in your user-global config,~/.gradle/gradle.properties:Gradle's launcher still starts under your newer default JDK, but the build runs on this one.org.gradle.java.home=/path/to/jdk-21 - Android SDK — create
local.properties(gitignored) at the repo root:sdk.dir=/home/you/Android/Sdk
./gradlew :app:assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
adb install app/build/outputs/apk/debug/app-debug.apkNo Android Studio required — a JDK plus the command-line SDK tools is enough:
# 1) A JDK 17–21. Use your distro package, SDKMAN!, or a Temurin tarball. E.g. Arch:
sudo pacman -S jdk21-openjdk # -> /usr/lib/jvm/java-21-openjdk
# If your default java is newer, add its path to ~/.gradle/gradle.properties
# as org.gradle.java.home=... (see above).
# 2) Android command-line tools -> ~/Android/Sdk (grab the current zip URL from
# https://developer.android.com/studio#command-tools)
mkdir -p ~/Android/Sdk/cmdline-tools && cd ~/Android/Sdk/cmdline-tools
curl -fLO https://dl.google.com/android/repository/commandlinetools-linux-XXXXXXXX_latest.zip
unzip -q commandlinetools-linux-*_latest.zip && mv cmdline-tools latest
yes | latest/bin/sdkmanager --sdk_root="$HOME/Android/Sdk" --licenses
latest/bin/sdkmanager --sdk_root="$HOME/Android/Sdk" \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
# 3) Point the build at the SDK
echo "sdk.dir=$HOME/Android/Sdk" > local.propertiesRelease builds are signed from keystore.properties at the repo root (gitignored).
A keystore (nextkeep-release.jks) is already generated with a placeholder password
— fine for personal sideloading. To use your own key instead:
keytool -genkeypair -v -keystore nextkeep-release.jks -storetype PKCS12 \
-alias nextkeep -keyalg RSA -keysize 2048 -validity 10000then update storeFile/storePassword/keyAlias/keyPassword in keystore.properties.
If that file is absent, the release build falls back to the debug key.
./gradlew :app:assembleRelease
# APK: app/build/outputs/apk/release/app-release.apk.github/workflows/build.yml builds the app in the cloud on every push and on version
tags, then publishes the APK so you can download it from a browser on any machine — no
JDK or Android SDK needed locally. The same file runs on two backends:
- GitHub — push or mirror the repo to GitHub.com; it builds on free hosted runners.
Grab the APK from the run's Artifacts, and pushing a tag like
v1.0also attaches it to a Release. - Gitea / Forgejo Actions — a self-hosted Gitea/Forgejo reads the same
.github/workflows/. It needs Actions enabled and a registeredact_runner(Docker); then the APK is downloadable from the run's Artifacts.
Both version fields are derived from git at build time (see app/build.gradle.kts),
so a build's version always matches the commit or tag it was cut from — there is nothing
to bump by hand:
versionName— the human version shown in Settings → About — isgit describe --tags --always --dirty=-devwith the leadingvstripped:- on a tagged commit → the exact tag, e.g.
v1.1→1.1; - a few commits past a tag →
<tag>-<commits>-g<hash>, e.g.1.1-3-g1a2b3c4; - before the first tag → a short commit hash; with uncommitted changes → a
-devsuffix.
- on a tagged commit → the exact tag, e.g.
versionCode— the integer Android actually compares for "is this an upgrade" — isgit rev-list --count HEAD, the commit count. It increases with every commit, so a later build always outranks an earlier one.
Both fall back gracefully (1.0-dev / 1) when git is unavailable, e.g. building from a
source archive. CI checks out with full history (fetch-depth: 0) so the tags resolve.
A release is generated when you push a v* tag. That fires
.github/workflows/build.yml, which builds the debug + release APKs and attaches them to
a GitHub Release named after the tag. (Pushing any branch or opening a PR builds the
APKs too, but only as downloadable artifacts — a v* tag is what publishes a Release.)
# from a clean main containing the changes you want to ship:
git push origin main # publish the commits (origin is Gitea; it mirrors to GitHub)
git tag v1.2
git push origin v1.2 # the tag triggers the release build + publishes the APKsPast releases and their changes are recorded in CHANGELOG.md.
For an installed app to update in place, the new APK must carry the same
signature — so release builds are signed with a stable key (from keystore.properties
locally, or the RELEASE_KEYSTORE_* CI secrets; without them CI falls back to the debug
key, which cannot update a stable-signed install).
NextKeep's updater (data/Updater.kt) asks the GitHub mirror for the latest release,
compares its tag against the running versionName, and — if newer — downloads that
release's app-release.apk (never the debug APK, which has the wrong signature) and
hands it to the system installer. The comparison looks only at the leading numeric
series (1.2 > 1.1), ignoring -rc1 / git-describe / -dev suffixes, so a dev build
sitting a few commits past the latest tag is not offered a pointless "update" back to it.
The first stable-signed release is the first
v*tag built after the stable-signing commit with the CI secrets set; earlier debug-signed builds (v1.0,v1.1-rc1) must be replaced by a one-time manual reinstall before in-place updates take over.
ui/ Compose screens + ViewModels (login, notes grid, editor, settings, lock)
markdown/ Editing transforms, block parser, and renderer (dependency-free)
qr/ nc://login parser + CameraX/ZXing QR scanner
data/
local/ Room: NoteEntity (with dirty/deleted flags), NoteDao, NotesDatabase
remote/ Retrofit client for the Notes API v1, basic-auth interceptor
AccountStore credentials, encrypted via CryptoManager (Keystore AES-GCM)
SettingsStore DataStore-backed app settings
Updater in-app updates: latest GitHub release -> download + install the APK
SyncWorker periodic background sync (WorkManager)
NotesRepository offline-first store + two-way sync engine
Notable design points:
- The Notes API derives a note's title from the first line of content, so the
Keep-style separate title field is joined/split transparently during sync
(
NotesRepository.joinContent/splitContent). The sync payload also sends thetitleexplicitly (it is read/write in API v1 and becomes the note's filename), so a note's file on the server is named after its title. - No DI framework — a small
AppContaineron theApplicationclass; ViewModels get it viaviewModelFactoryinitializers. - Min SDK 26, target SDK 35.
- Bound by the Notes API v1. A note is a markdown file with title/category/ favorite/modified — so there is no server support for per-note color, reminders, image attachments, or archive. Those Keep features would be local-only or aren't feasible.
- Release builds are HTTPS-only (
usesCleartextTraffic=false); a debug-only manifest overlay (src/debug/AndroidManifest.xml) re-enables cleartext so debug builds can hit a local test server. If you self-host on plainhttp, build debug or relax the release flag. - App lock needs Android 12+ for the credential fallback (
BIOMETRIC_WEAK or DEVICE_CREDENTIAL); on older versions the toggle is unavailable unless a biometric is enrolled. - The markdown renderer covers a common subset (headings, lists, task lists, quotes, fenced code, dividers, inline bold/italic/code/strikethrough/links) — no tables or deeply nested lists.
- In-app updates need sideload permission and signature continuity. The first run of the installer prompts to allow installing apps from NextKeep, and only same-key (stable-signed) releases can update in place — see Versioning and releases.
./gradlew :app:testDebugUnitTest runs the JVM unit tests:
ContentMappingTest— title/body ⇄ content round-trip (guards against notes being silently mutated during sync).QrLoginParseTest— parsing ofnc://login/...QR codes (any field order, URLs with ports/paths).MarkdownTest— the formatting transforms (MarkdownEditing), block parser, and plain-text stripper.UpdaterTest— the in-app updater's version-series comparison (thevprefix, rc/git-describe/-devsuffixes) and release-APK selection.
For manual testing without a real server, tools/mock_notes_server.py is a tiny
in-memory mock of the Notes API v1 that seeds a few markdown notes:
python3 tools/mock_notes_server.py 8088 # on the host
# in the app (debug build) log in to http://10.0.2.2:8088 from an emulator