Skip to content

Photograph the store screenshots from a recipe, not by hand - #116

Merged
rbioteau merged 3 commits into
mainfrom
store-screenshots
Sep 19, 2026
Merged

rbioteau merged 3 commits into
mainfrom
store-screenshots

Conversation

@rbioteau

Copy link
Copy Markdown
Owner

Five screenshots per listing, and nothing about taking them by hand is
reproducible — "Continue — Ch. 12" is a different chapter every afternoon, and
the language is whichever one the device happens to be in. So this is a recipe
that makes the state it photographs: it signs in from cleared app data,
forces the language (one run serves both listings), marks a chapter read so
Continue has somewhere to continue, and waits for a copy to land before the
offline screen.

  • tool/store_screenshots.sh — the half that cannot be Dart: clearing the
    app's data, refusing a screen that is not 9:16, flattening the PNGs (both
    stores refuse an alpha channel).
  • integration_test/store_screenshots_test.dart — the walk itself.
  • test_driver/store_screenshots.dart — the half with a filesystem.
  • store/CLAUDE.md — the sizes each console demands, and how each asset is
    made. It rides with store/feature-graphic.html, the source of the Play
    banner, so that the graphic can be regenerated instead of redrawn.

It finds its way by icon and by type rather than by label: the sign-in form is
answered before a language has been forced, so it is found positionally, and
the few strings it needs come out of the app's own AppLocalizations rather
than being written down a second time.

What is verified, and what is not

Verified: flutter analyze is clean on top of main (which caught the rename
this rebase was for — SavePill became DownloadPill in 819b8ca, and the
recipe followed it); flutter test passes; every flutter drive flag in the
script exists in this SDK; every guard in the script was exercised by hand
(missing credentials, a bad locale, an unknown argument, a device whose screen
is not 9:16, a device that is not there); the integration_test API was read
out of the SDK rather than remembered.

Not verified: the run itself. It has never been executed — there is no
library to point it at yet — and the first run will be the first debug. The
steps to watch are the profile picker's face icon, the reader's chrome tap and
the swiped chapter row. store/CLAUDE.md says so where the assets live.

Note

This supersedes the commit left on site-and-licences after #114 merged: it is
rebased on main instead, so the diff here is only the recipe.

Five screenshots per listing, and nothing about taking them by hand is
reproducible: "Continue — Ch. 12" is a different chapter every afternoon, the
language is whichever one the device happens to be in, and two runs show two
different libraries. So the recipe makes the state it photographs rather than
assuming it — it signs in from cleared app data, forces the language (one run
serves both listings), marks a chapter read so Continue has somewhere to
continue, and waits for a copy to land before the offline screen.

`tool/store_screenshots.sh` is the half that cannot be Dart: clearing the app's
data, refusing a screen that is not 9:16 (Play's recommended size is 1080x1920,
and no phone ships that shape), and flattening the PNGs, because both stores
refuse an alpha channel. The recipe is
`integration_test/store_screenshots_test.dart`, the half with a filesystem is
`test_driver/store_screenshots.dart`, and `flutter_driver` and
`integration_test` both come from the SDK.

It finds its way by icon and by type rather than by label: the sign-in form is
answered before any language has been forced, so it is found positionally, and
the few strings it needs are read out of the app's own `AppLocalizations`
instead of being written down a second time.

**It has never been run.** It type-checks against the SDK's `integration_test`
API, and every finder in it is an icon or a string the app itself draws, so it
should fail loudly rather than silently — but the first run will be the first
debug, and the profile picker, the reader's chrome and the swiped row are the
steps to watch. `store/CLAUDE.md` says so where the assets are described, along
with the size each console demands.

`store/feature-graphic.html` rides along: the source of the 1024x500 Play
wants, drawn on ivory rather than on the night blue, because Play asks for a
background that is not dark grey — its own store is.
The recipe went to a phone and failed in five places, none of which the
analyser could see. Each one is fixed here, and each says in the code where it
bit, because the next person to touch this will hit the same five:

- the sign-in form is answered **positionally** — it is reached before any
  language has been forced — and submitted through the keyboard's *done*, which
  is the password field's own `onFieldSubmitted`. A tap on the button is a tap
  the soft keyboard is sitting on;
- the profile face is `ProfileAvatar` in an `IconButton`, not one of Material's
  face icons;
- a saved copy is a **check inside a circle**: the word "Saved" is only its
  tooltip, so waiting for that text waits forever;
- `pumpAndSettle` never returns on a screen that is loading, and this app is
  always loading something — a shelf shimmers, a page spins. The recipe pumps a
  fixed beat, and every navigation *waits for the thing it navigated to*;
- the bottom-bar destinations are found by **position in the bar**, not by
  icon: the label is a word the bar hides when it does not fit, and a tap aimed
  at an `Icon` is one the framework can refuse as not hit-testing the widget.

The script changes with them: it **uninstalls** the app rather than `pm
clear`ing it, because clearing needs a permission `adb shell` does not have and
fails with a `SecurityException` — swallowing that is how the first run
photographed the profiles of whoever used the phone last. It also resolves
`adb` out of the SDK (it is rarely on PATH) and refuses to flatten a run that
wrote nothing.

One step is still broken and is written down in `store/CLAUDE.md`: a tap on a
bottom-bar destination is not delivered when the test synthesises it, while
`adb shell input tap` at the same point switches tab. Two screens come out; the
last three need that understood first.
A store listing is a publication, and the app draws somebody else's covers and
pages: no library means no screenshots, and any library means showing what the
app was pointed at on a listing that is meant to show what the app is. So the
content is faked one layer below the app instead — the layer that owns it.

`tool/gen_demo_library.sh` draws four covers, six pages and an avatar with
ImageMagick, in the app's own palette and its own bundled face, and names the
series in a `titles.txt` that the server reads back, so the shelf, the series
screen and the artwork cannot disagree about what a series is called.
`tool/demo_server.dart` serves them as a Kavita server: login, health,
libraries, series, volumes, chapter info, page images, progress and the device
registration, on `localhost:5000`, against any password.

Its routes are the shapes `lib/src/api/models.dart` reads, taken from
`docs/openapi/kavita-openapi-0.9.0.0.json` — pinned in this repository, and the
only reason the faking is cheap: the contract was already written down. Every
route was checked with `curl` before the app ever saw it, which caught a
nesting bug in the page count (a `fold` seeded with the outer accumulator
counts the first volume twice: thirty pages, reported as forty-eight) and a
write that ignored `chapterIds` — the shape `mark-multiple-read` uses — so a
swipe on a row would have changed nothing on screen and nothing in the file.

It is a `tool/`, and that is the point: it is not in the app, not in a bundle,
and not behind a flag anybody could turn on. A fake-content mode inside the app
would be a second way for every screen to draw, and this is not.
@rbioteau
rbioteau merged commit cfbb660 into main Sep 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant