No description
  • Scala 80.8%
  • Nix 9.9%
  • Shell 6.4%
  • Just 2.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Børge Lundsaunet fb773f2abc Take binky-base from the forge, not from /home/blund
The flake input pointed at a path on one laptop, which meant this repo
built on exactly one machine and nowhere else. It now comes from
git.lundsaunet.no/blund/binky-base over anonymous https, so no key is
needed to evaluate it.

Checkout builds still compile the sibling ../binky-base; only the
hermetic path changes.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-fable-5)

Co-Authored-By: eca-agent <git@eca.dev>
2026-08-12 22:23:39 +02:00
app http feeds: upgrade first, then ask 2026-08-12 21:29:38 +02:00
docs binky-reader: a page-turning feed reader 2026-08-12 17:49:34 +02:00
scripts http feeds: upgrade first, then ask 2026-08-12 21:29:38 +02:00
test http feeds: upgrade first, then ask 2026-08-12 21:29:38 +02:00
.envrc Sync list rows instead of rebuilding screens 2026-08-12 18:11:00 +02:00
.gitignore binky-reader: a page-turning feed reader 2026-08-12 17:49:34 +02:00
flake.lock Take binky-base from the forge, not from /home/blund 2026-08-12 22:23:39 +02:00
flake.nix Take binky-base from the forge, not from /home/blund 2026-08-12 22:23:39 +02:00
justfile An emulator scenario: subscriptions that survive being killed 2026-08-12 19:57:48 +02:00
README.md An emulator scenario: subscriptions that survive being killed 2026-08-12 19:57:48 +02:00

binky the octopus

binky reader

A minimal black & white feed reader for the Mudita Kompakt (e-ink, AOSP 12). Part of the binky suite, alongside binky-chat and binky-notes; the look comes from the shared binky-base module.

Reading is the one thing an e-ink panel does better than every other screen you own. This is the app that leans on that.

Pages, not scrolling

The reader turns pages. E-ink hates scrolling — a partial repaint mid-drag is the panel's worst case — but replacing one still page with another is exactly what it is built for.

  • Tap the right half of the text to go forward, the left half to go back; the footer says where you are.
  • Page breaks are computed from the real layout, greedily: a break only happens where the next line genuinely would not fit.

The screens

  • feeds — your subscriptions, each with its unread count, plus everything on top. Paste a URL at the bottom to subscribe (https:// is assumed). The octo holds the bottom of the page.
  • entries — newest first. Unread items carry the black edge bar; read ones are plain boxes. State by structure, never by colour.
  • read — the article as plain text, one page at a time. open hands the original link to a browser for feeds that only ship summaries.

The pure core

Everything the reader actually thinks about is Android-free and checked on a plain JVM:

  • Html.scala — feed markup → plain text. A scanner, not a parser: it never fails, it just gets less clever on malformed input. The interesting law is that decoding is a left inverse of escaping, text(escape(s)) == normalize(s), which keeps the entity table and the scanner honest about each other. normalize is a genuine normal form (idempotent, no stray whitespace).
  • Page.scala — the pagination arithmetic: line bottoms + viewport height → page breaks. Laws: pages tile the article exactly, every page fits unless a single line is taller than the screen, every break is maximal (greedy), and a bigger viewport never needs more pages.
  • Feeds.scala — RSS 2.0, RSS 1.0/RDF and Atom into one model. The dialects disagree about every name (item/entry, pubDate/published, description/summary) but agree about the shape, so the disagreement is collapsed once, here. The law that keeps it honest: the same two articles written in both dialects parse to the same entries.
just test      # ~6.5k law checks on a plain JVM
just check     # the same, hermetically, plus the APK build
just test-emu  # the APK on an emulator, against a fixture feed

The laws earn their keep: the first run caught a bare < in prose (which feeds are supposed to escape and routinely don't) swallowing the rest of an article.

Laws stop at the process boundary, though, and that is exactly where this app lost a subscription once: loading the feed list threw, became an empty list, and the next save wrote that emptiness down. So test/emu/scenario.sh subscribes to a feed served out of test/fixtures, reads an article, restarts the app, and insists everything is still there. It runs on binky-base's harness.

Design

Follows the binky design language (see binky-base/docs/design.md): two colors, no animations, bordered boxes, sans-serif, plain Android Views built programmatically in Scala 3 — no Compose, no AppCompat, no fragments, no XML layouts.

Storage

Feeds and entries are two JSON blobs in SharedPreferences. Entries carry the feed's own ids, so a refetch recognises what it has already seen and the read flag survives it; the newest 60 per feed are kept. Store.scala is the single seam.

Building

just ship        # nix build .#apk (remote-buildable) + sign locally + sideload
just apk         # build + sign only
just apk-remote  # force the remote builder (fails rather than build locally)
just apk-local   # everything on this machine, no store involved
just check       # core laws + APK build, hermetically

The pipeline is Gradle-free: aapt2 → javac (R) → scalac → d8 → zip → zipalign → apksigner. The only runtime dependency is ujson, pinned in scripts/deps.txt and fetched once as a fixed-output derivation, so the APK itself builds fully sandboxed (and therefore on a remote builder). Signing stays on the dev machine: the debug key never enters the nix store.

The pipeline itself lives in binky-base — it is the same for every app in the suite, so scripts/build-apk.sh here is a five-line wrapper naming the app. binky-base is a source dependency: checkout builds compile a sibling ../binky-base (override with BASE_DIR), while the hermetic build takes it as a locked flake input.

Layout

app/
  AndroidManifest.xml    package ink.reader, INTERNET only
  res/values/strings.xml the app name; everything else comes from binky-base
  src/ink/reader/
    MainActivity.scala   single-activity UI: feeds / entries / reading
    Model.scala          Feed and Entry (pure)
    Html.scala           markup → plain text (pure)
    Page.scala           pagination arithmetic (pure)
    Feeds.scala          RSS + Atom → one model (pure)
    Codec.scala          the JSON shape of a feed (pure; primitives from ink.data)
    Store.scala          JSON persistence
    Http.scala           the effectful edge: one blocking GET
test/
  ReaderTest.scala       law checks for the pure core
scripts/
  build-apk.sh           thin wrapper; the pipeline itself lives in binky-base
  deps.txt               pinned Maven coordinates (single source of truth)
  test-reader.sh         run the JVM law checks from the checkout
justfile                 the impure bits: signing, sideloading
flake.nix                packages (deps, apk), checks (reader-laws, apk), apps, dev shell

Not yet

Feeds are fetched only when you ask (no background sync — that is a service, a notification channel and a battery argument, and the Kompakt has opinions). Full-article extraction is out of scope: if a feed ships a summary, open is the answer.