Extends ~/.local/bin/todo to Android without turning it into an app. Notes stay plain markdown in ~/docs/notes/todo; this repo holds the tooling. Key finding: files.rcjohnstone.com already serves /home/connor/docs over authenticated WebDAV, and sftpgo and syncthing mount the same host directory, so today's note is already reachable at a public TLS URL. No new service, hostname, Caddy block, or credential store is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NctNN59e573MxYdNWzNHUw
19 KiB
todo: daily notes, everywhere
Plan of record. Written 2026-09-09.
1. What this is
~/.local/bin/todo is a 15-line zsh script that has outlasted every notes app I
have tried. If today's file does not exist, copy the most recent one forward,
rewrite the date, drop into nvim. That is the whole idea and the whole appeal.
This project extends it to Android without making it into an app. Specifically:
- One tap from the Android homescreen into today's note, in a real text editor.
- Edits sync to the laptop without anyone thinking about sync.
- The notes stay plain markdown in
~/docs/notes/todo/, readable and editable withcatandnvimforever.
The notes live in ~/docs/notes/todo/. This repo (~/docs/projects/todo/)
holds the tooling: the rollover script, the systemd units, and the Android app.
The two never merge.
2. Current state, as verified 2026-09-09
Facts established by inspection, not assumption.
Laptop (sctfw004)
- 232 note files,
2024-07-29.mdthrough2026-09-09.md. - 4 leftover Syncthing conflict files:
2024-10-21,2024-11-12,2025-01-16,2025-01-17. - No syncthing binary installed. This laptop was never joined to the mesh.
- JDK 21.0.12 present.
ANDROID_HOME=/home/connorjohnstone/Android/Sdkis exported but the directory does not exist. 24 cores, 62 GB RAM, 1.6 TB free.
Home server (mainframe, 10.0.0.2)
Everything runs as a rootful podman-compose stack defined in
/home/connor/compose.yml. The host's own caddy systemd unit is inactive and
is a decoy. The live one is the container connor_caddy_1.
Relevant mounts, both pointing at the same host directory:
| Service | Mount |
|---|---|
| sftpgo | /home/connor/docs -> /srv/sftpgo/data/connor/docs |
| syncthing | /home/connor/docs -> /var/syncthing/Sync |
/home/connor/docs/notes/todo/has 168 files, newest2026-03-30.md. Stale since March, fed by a machine that no longer exists.- Laptop is a strict superset of mainframe. Nothing on mainframe is missing
locally. Only two shared files differ in content,
2024-10-21.mdand2025-01-16.md, both dates that also carry a conflict file.
The endpoint that makes this easy
/home/connor/Caddyfile (553 lines) splits public from private with a
# ddns: public comment marker above each site block. No marker means no public
DNS record, which means no HTTP-01 validation, which means no public cert and no
Certificate Transparency entry. The Caddyfile documents this at length. Do not
undermine it.
Two SFTPGo blocks, and the difference between them is the point:
# ddns: public
sftp.rcjohnstone.com { # admin UI, browser-facing
import authelia
...
reverse_proxy sftpgo:8080
}
# ddns: public
files.rcjohnstone.com { # WebDAV, native-app-facing
import secure_headers
import accesslog
reverse_proxy sftpgo:10080
}
files. deliberately has no import authelia. The Caddyfile's own comment on
the authelia snippet explains why:
Identity gateway. Browser-facing services only. Native apps (Immich, Jellyfin, CalDAV, WebDAV, Subsonic, ntfy) cannot complete an interactive login, so they are covered by OIDC or by CrowdSec instead, not by bypass rules that would hollow this out.
Confirmed live from the LAN:
$ curl -I -X PROPFIND https://files.rcjohnstone.com/
HTTP/2 401
server: SFTPGo/2.7.5
www-authenticate: Basic realm="SFTPGo_2.7.5 WebDAV"
So https://files.rcjohnstone.com/docs/notes/todo/2026-09-09.md is already a
public, TLS-protected, authenticated URL for exactly the file we care about.
No new service, hostname, Caddy block, DNS record, or credential store is
needed. An Android app doing Basic auth against files. is the established
pattern in this stack, not an exception carved into it.
3. Architecture
/home/connor/docs/notes/todo/ <- canonical
|
+------------------+------------------+
| |
systemd timer 00:01 SFTPGo WebDAV :10080
(rollover + prune) |
files.rcjohnstone.com
|
+---------------+---------------+
| |
laptop: todo script Android app
(curl pull, nvim, push) (GET/PUT, offline cache)
Four decisions worth recording:
Rollover moves to the server. Previously the client created today's file.
Two clients doing that independently produce a guaranteed conflict, which is the
most likely origin of the four existing conflict files. A single writer on the
always-on box removes that failure mode by construction. Every client degrades
to "open the file named $(date -I).md", which is a much lower bar.
Syncthing is dropped from this path. It is a second file-access channel with
a second auth model and a second conflict convention, and it is currently broken
anyway. WebDAV already exists, is already public, and is already authenticated.
One protocol, one credential, one conflict strategy. Syncthing keeps running for
whatever else uses docs/, this just stops depending on it.
Basic auth, existing connor SFTPGo user. No second scoped user. Decided
deliberately: the added blast-radius protection was not judged worth the extra
account to manage.
Native app, not a PWA. PWAs feel clunky, and the soft-keyboard and scroll
behavior of a real BasicTextField is the entire experience here.
4. Rollover and the prune rule
The timer runs at 00:01 America/Louisville. It copies the most recent file to
today, rewrites line 1 to # <today>, and prunes completed work.
Prune semantics
Only whole top-level checkbox subtrees are eligible. A top-level checkbox
item is one with no checkbox ancestor inside its section (headings reset scope).
Drop a top-level item together with its entire subtree if that item is [x]
and every checkbox in its subtree is also [x]. Otherwise keep the subtree
completely intact, finished children included.
Three consequences, all intended:
- Finished children of an unfinished parent stay. Grouping is how context
gets carried, so a
[x]child under a[ ]parent is information, not cruft. - Non-checkbox bullets are never touched. Only
- [ ]and- [x]rows are eligible. The prose bullets under### DSA Election Research Toolsurvive untouched. - Headings are never removed, even if a section empties out. Removing them would mean making structural judgments the rule cannot justify.
Worked against the real 2026-09-08.md:
- [ ] Add OpenID to any apps that I control KEPT (top-level, unchecked)
- [x] Runway KEPT (finished child, context)
- [ ] Shanty KEPT
- [x] Climb DROPPED (top-level, complete)
- [x] Have Claude re-do the news app DROPPED
Validation before deployment
Run the prune across all 232 historical files and review the diff before the timer ever touches a live file. This is the piece with the most semantic risk and the recorded data to test it against already exists. Do not skip this.
5. Operational constraints: CrowdSec
files.rcjohnstone.com imports accesslog, so CrowdSec parses every request the
app makes. Bans are 4 hours, IP-scoped. There is no whitelist covering a
phone on cellular. Collections in play: crowdsecurity/caddy, http-cve,
base-http-scenarios, whitelist-good-actors.
| Scenario | Trigger | Client rule |
|---|---|---|
http-generic-bf |
repeated 401/403 from one IP | Never retry a 401. Surface a credential error and stop. |
http-probing |
repeated 404s | A 404 means create, not poll. One PUT, never a retry loop. |
http-crawl-non_statics |
aggressive non-static fetching | Sync on foreground and on save only. No background polling. |
The 404 case is real, not theoretical: the app can legitimately ask for today's file before the 00:01 timer has run, given clock skew or travel across a time zone.
Non-issue, checked: there is no sftpgo.json, so SFTPGo runs on defaults, which
means its own defender is disabled and its rate limiter is inert. All ban risk
is CrowdSec's, and all of it is avoidable in client behavior.
Set a distinct User-Agent so these requests are legible in the access log.
OkHttp's default (okhttp/x.y.z) does not match http-bad-user-agent, but a
named agent makes debugging much easier.
6. Changes to ~/.local/bin/todo
Current script:
cd ~/docs/notes/todo
today="$(date --iso-8601)"
current="$today.md"
previous="$(ls | sort | tail -n 1)"
previous_date="$(echo "$previous" | sed "s/.md//")"
if ! [ -e $current ]; then
cp $previous $current
sed -i "s/$previous_date/$today/g" $current
fi
nvim $current && cd -
Three defects:
previouscan select a conflict file.lsglobs everything, so2024-10-21.sync-conflict-*.mdis a candidate. Ordering has been lucky so far. Constrain the glob to[0-9]*-[0-9]*-[0-9]*.md.- The
sedis a global replace.s/$previous_date/$today/grewrites that date anywhere in the body. Write "meeting moved to 2026-09-08" in a note and it silently becomes 09-09 tomorrow. Only line 1 is wanted:sed -i "1s/.*/# $today/". && cd -is dead. The script runs in its own process, so neithercdaffects the calling shell.
Then add WebDAV pull before nvim and push after. If the pull fails, edit the local copy and push later. Rollover is no longer the script's job, but keep a local fallback for the case where the server is unreachable and today's file does not exist yet.
7. The Android app
One screen. BasicTextField, OkHttp, GET and PUT against
https://files.rcjohnstone.com/docs/notes/todo/<date>.md. Credentials in
EncryptedSharedPreferences.
Editing
Source-mode highlighting: markers stay visible and get styled in place.
**bold** renders bold with the asterisks still showing. This is a deliberate
call. Hiding markers requires a non-identity OffsetMapping, which is fiddly
and makes the cursor jump in ways that feel broken. Keeping every character
means offset mapping is identity, so cursor and selection math is simply
correct. It also stays honest plain text, which is the entire point of the
system.
Implemented as a VisualTransformation producing an AnnotatedString:
| Syntax | Rendering |
|---|---|
# through #### |
bold, scaled by level |
**bold** |
bold |
*italic*, _italic_ |
italic |
`code` |
monospace, tinted background |
~single~ and ~~double~~ |
strikethrough (single-tilde is what I actually write, double is what CommonMark specifies, support both) |
[text](url) |
colored |
- [x] ... |
whole line dimmed and struck |
Tap-to-toggle. onTextLayout yields a TextLayoutResult, and
pointerInput plus getOffsetForPosition turns a tap into a character offset.
Flip the checkbox only when that offset lands inside the [ ]/[x] span
itself, not anywhere on the line. A tap anywhere else places the cursor like a
normal text field, so the gesture never fights ordinary editing.
The transformation runs on every keystroke across the whole document. Files are around 4 KB so this is fine, but memoize on a text hash anyway.
Sync and offline
The local file is what the editor reads and writes, always. The app opens instantly and works with no signal. Alongside it, keep the pristine copy from the last sync and its ETag.
On foreground and on save: GET, compare ETags. Match means PUT. Mismatch
means a three-way merge from base, mine, and theirs, which for checkbox toggles
on distinct lines resolves cleanly in essentially every real case. A genuine
same-line collision writes a .sync-conflict- sibling, matching the convention
already present in the directory.
To verify in phase 1: SFTPGo's WebDAV is golang.org/x/net/webdav
underneath. It returns ETags, but it is unclear whether it honors If-Match on
PUT. If it does not, fall back to HEAD-compare-then-PUT. That leaves a race
window of milliseconds for a single person with one phone, which is acceptable.
8. Android development primer
Requested explicitly. This is the part of the stack I have never touched, and the plan is to actually understand it rather than let it stay a black box.
The build
An APK is a zip: compiled Dalvik bytecode (classes.dex), resources, the
manifest, and a signature. An AAB is what Play wants instead, but for
sideloading via adb an APK is what you need.
Gradle is the build tool and AGP (Android Gradle Plugin) is the plugin that teaches Gradle about Android. Version pairs matter: AGP 8.x needs JDK 17 or newer, and JDK 21 is present, so that is fine.
The SDK at $ANDROID_HOME is three things that get confused constantly:
platforms/android-NN/, the compile-time API stubs, one per API level.build-tools/x.y.z/, the tools that make an APK (aapt2,d8,apksigner).platform-tools/, the device tools, most importantlyadb.
You do not need Android Studio to build. cmdline-tools plus sdkmanager
is enough, and that is the plan here. Studio is still genuinely useful for
Layout Inspector and for reading logcat comfortably, so it may be worth
installing later even if the build never depends on it.
The three SDK versions
These are different on purpose and mixing them up is the classic beginner error:
compileSdkis what you compile against. Use the newest. It only decides which APIs the compiler will let you name.minSdkis the oldest Android that will install the app. Lower means more devices and more compatibility shims. For a personal app, set it to whatever the phone actually runs.targetSdkis a promise: "I have tested against this API level's behavior changes." Android applies compatibility behavior for anything older. This is the one with real semantics attached.
The manifest and the launcher icon
AndroidManifest.xml declares components and permissions. A homescreen icon is
not a setting, it is an <activity> carrying an <intent-filter> with
MAIN plus LAUNCHER. That is literally what "an app on the homescreen" means.
INTERNET is a normal permission: declared in the manifest, granted at
install, no runtime prompt. Because our file lives on the server and the cache
lives in app-private internal storage, no storage permission is needed at
all, and scoped storage and SAF never enter the picture. That is a large
category of Android pain avoided by architecture rather than by fighting it.
Lifecycle, and why the sync design depends on it
An Activity moves through onCreate / onStart / onResume / onPause /
onStop / onDestroy. Android can and does destroy your process whenever it
likes. Two direct consequences for this app:
- "Sync on foreground" means
onResume(or a lifecycle-aware coroutine scope), which is the hook that fires when the user returns to the app. - Never treat in-memory state as durable. Text must be persisted to the local file as it changes, because the process can vanish without warning.
Compose
Jetpack Compose is the declarative UI toolkit that replaced XML layouts and
findViewById. You write functions annotated @Composable that describe the UI
as a function of state. When state changes, Compose re-invokes the affected
functions. That re-invocation is recomposition.
Two ideas do most of the work:
remember { mutableStateOf(x) }creates state that survives recomposition and triggers it on write.- State hoisting: a composable takes its value plus an
onValueChangecallback rather than owning state internally. This is whyBasicTextFieldtakesvalueandonValueChange, and why the document lives above it.
Coroutines
Network I/O on the main thread throws NetworkOnMainThreadException. Kotlin
coroutines are the answer: suspend functions that can pause without
blocking a thread. viewModelScope.launch { ... } for work that should die with
the screen, Dispatchers.IO for blocking I/O. OkHttp has suspend-friendly
wrappers.
Signing, and the one irreversible mistake
Every APK must be signed. Debug builds use an auto-generated debug keystore and that is fine for sideloading. A release build needs a keystore you create.
Back that keystore up. Android identifies an app by package name plus signing key. Lose the key and you can never upgrade the installed app again, you can only uninstall and reinstall, which discards its data. This is the one genuinely unrecoverable Android mistake, and it is worth putting the keystore somewhere durable on day one. Not in this repo, since this repo will be git-tracked.
Getting it onto the phone
adb install -r app-debug.apk over USB. Enable Developer Options (tap Build
Number seven times in Settings), then USB debugging. Wireless debugging also
works well and avoids the cable: pair once with adb pair, then
adb connect <ip>:<port>.
Test on the real phone rather than an emulator. The emulator is slow, and every interesting question here (soft keyboard behavior, scroll physics, tap targets, what the text actually looks like) is a question about the real device.
9. Phases
Phase 0. Reconcile.
Verify the authenticated WebDAV path end to end with real credentials. Resolve
the two divergent files (2024-10-21.md, 2025-01-16.md) against their conflict
files, then delete all four stragglers. Push laptop to server, laptop wins,
since it is a strict superset. Initialize this repo.
Phase 1. Server rollover.
Write the prune script. Run it across all 232 historical files and review the
diff. Only then install the systemd timer on mainframe. Also settle the
If-Match question against the live SFTPGo.
Phase 2. Laptop client.
Three fixes to todo, plus curl pull and push. At the end of this phase the
laptop half is done and the notes are live on the server again.
Phase 3. Android, ugly version.
Install cmdline-tools into the existing empty ~/Android/Sdk. Build a
deliberately plain app: fetch, edit, save, launcher icon, nothing else. Get it
on the phone early, before there is anything worth throwing away. This is also
the phase where the primer above stops being theory.
Phase 4. The editor. Syntax highlighting and tap-to-toggle.
Phase 5. Offline. Local cache, ETag tracking, three-way merge, conflict siblings.
Phases 3 through 5 build on the laptop, not on mainframe. The build is small,
and the artifact has to reach the phone by adb from whatever machine is next
to it.
10. Open questions
- Phone's actual Android version, to set
minSdk. - Where the release keystore should live. Not in this repo.
- Whether the laptop should keep a Syncthing membership for the rest of
docs/, independent of this project.
11. Repo layout (proposed)
~/docs/projects/todo/
PLAN.md this file
rollover/ prune script + systemd unit and timer
client/ revised todo script
android/ the app