Files
Connor JohnstoneandClaude Opus 5 275ecd78ac Add plan of record for cross-device todo notes
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
2026-09-09 17:53:32 -04:00

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 with cat and nvim forever.

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.md through 2026-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/Sdk is 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, newest 2026-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.md and 2025-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 Tool survive 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:

  1. previous can select a conflict file. ls globs everything, so 2024-10-21.sync-conflict-*.md is a candidate. Ordering has been lucky so far. Constrain the glob to [0-9]*-[0-9]*-[0-9]*.md.
  2. The sed is a global replace. s/$previous_date/$today/g rewrites 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/".
  3. && cd - is dead. The script runs in its own process, so neither cd affects 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 importantly adb.

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:

  • compileSdk is what you compile against. Use the newest. It only decides which APIs the compiler will let you name.
  • minSdk is 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.
  • targetSdk is 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 onValueChange callback rather than owning state internally. This is why BasicTextField takes value and onValueChange, 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