Orientation
What we learned
Three episodes that left permanent marks on the code, and why the guards you will find are there.
# Why this page exists
Someone reading this codebase cold will find guards, tests and comments that look excessive without knowing what they are for — and will eventually remove one. This page is why they are there.
# Getting an account blocked, twice
Twice, our test account was blocked by its provider's firewall. Not rate-limited — blocked, returning an error on every API call, from every port, from plain curl outside the app. Streams still played; the catalogue and the guide did not.
We caused it, and the second time is the instructive one, because the first fix looked complete.
Guide prefetch is the only thing that issues requests in bulk — one call per channel — so it is the only thing that can get an account flagged. After the first block we capped the fetches at three concurrent, which sounds modest. It is not:
A concurrency cap is not a rate limit. Three in flight at 100 ms each is about thirty requests a second.
Combined with a prefetch that fired per row entering composition — and a fling composes hundreds of rows in seconds — that is a flood. The television had a focus-settle delay for exactly this reason and the phone did not.
Four guards exist now, and all four are easy to remove by accident:
- Prefetch fires on scroll settling, not on composition. Rows flown past are not rows anyone read.
- A token bucket inside the client's request path, so every call passes it: a burst of eight, refilling one per 400 ms. The burst matters — a refresh is authentication plus six catalogue calls and must not be slowed — and the refill is the backstop that survives a future caller reintroducing a storm from a screen nobody has written yet.
- A block gate covering every call path: refresh, guide, series details and film details alike. The catalogue walk stops at the first refusal; four of its seven calls used to swallow a block as "this account has no films" and carry on.
- The backoff is persisted. The first thing a blocked user does is force-stop the app and reopen it, which used to clear an in-memory deadline and send it straight back to asking.
We keep tests asserting that after one block not a further byte is sent, that a mid-catalogue block ends a refresh at four requests, and that a stored block survives a restart. Keep them.
# The features that were not there
At one point our own feature list advertised nine things that had no path to them from a running app: a guide grid, a zap bar, a continue-watching row, theme palettes, timeshift, buffer modes, a bitrate cap, aspect ratio modes and a seek interval.
The settings screen that supposedly configured four of them wrote to state that was never
persisted, and two of its four controls had an empty click handler with a hardcoded selection.
Picture-in-picture could never have worked: the manifest flag was absent, so the call threw
into a bare catch every time.
We deleted the hollow features and corrected the list rather than finishing them under pressure. Anything deleted is recoverable from history, so bringing one back later means implementing it properly, not reverting a deletion.
The test we used has become our standard for whether a feature exists:
Can you reach it from a running app, and does changing it change what you see?
It is why the television settings screen omits theme mode instead of showing a switch that does nothing, and why our acceptance criterion for that screen is worded as "changing one changes what the app does — not merely what the screen says".
# The checks that had never run
For most of this project's life there was a CI workflow in the repository and no remote to run it on. It described a thorough gate: assemble, test, detekt, coverage, Lint, licence headers.
The first run to actually execute failed on the first step, because gradlew had
been committed without its executable bit from a system that has no such bit. Behind that,
detektAll had been broken for its entire existence without anyone noticing, and
the acceptance sweep described the licence-header check as "re-checked on every CI run" when
it had only ever been run by hand.
An unrun check and a passing check look identical from the inside. Both produce no failures.
This is why the wiki distinguishes what is verified mechanically from what has been seen working, and why the sweep grades its evidence instead of listing ticks. A claim about what is checked is worth exactly as much as the last time somebody watched the check fail on purpose.
# What the three have in common
Each was a case of something looking finished from the inside.
The concurrency cap looked like a rate limit. The settings screen looked like it configured the player. The CI workflow looked like a gate. In every case the gap was only visible from outside — from the provider's firewall, from a user pressing the control, from a runner that had never been asked to try.
The same shape keeps recurring in smaller ways, which is the useful part. A token bucket that paced requests at twice its documented rate for its whole life. A Koin module whose arguments had shifted by one and compiled perfectly. A player retry loop that succeeded every time and so never surfaced an error, while stuttering every few seconds.
None of those could be found by reading the code, because in each case the code said what its author meant. That is the reasoning behind binary acceptance criteria, behind measuring rather than arguing, and behind keeping a separate record of what was actually observed on hardware as opposed to what the code appears to do.