docs: the video's link is its Gitea page, not the raw URL, which its own CSP blocks

Checked the way a browser does it rather than the way curl does, and curl had given a false green:
Gitea sends `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox` with
every /raw/ response. That is deliberate - it is what makes serving raw HTML and SVG safe - but it
also blocks the media load when a browser navigates straight to a video, so the tab stays blank with
no error. The same URL plays fine as a subresource, which is exactly why the file's page in Gitea
shows a player: that page embeds <video controls src=".../raw/branch/main/...mp4">.

So the declaration's link is the /src/ page URL, and /raw/ is documented as the curl-and-download
URL instead. Everything verified about both URLs is written down in step 5 - including the two ways
to get a player with no Gitea chrome around it, if that is ever wanted: an unlisted YouTube upload,
or dropping the header for this one path on the Gitea host's Caddy proxy, which must stay scoped to
store-assets/ precisely because that header is what protects raw HTML and SVG.

The file itself was never the problem: 200, video/mp4, Content-Disposition: inline, bytes
sha256-identical to the recording, and Range: bytes=0-99 -> 206 with the matching Content-Range.
This commit is contained in:
2026-10-14 13:28:49 +02:00
parent 31a0edbe61
commit 68dcbc5db6
4 changed files with 42 additions and 21 deletions
+4 -2
View File
@@ -192,8 +192,10 @@ device, the tablet sets on AOSP AVDs; see [docs/STORE_LISTING.md](docs/STORE_LIS
`python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is `python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is
captured over, so the listing never shows a real one. The finished assets are **committed** — captured over, so the listing never shows a real one. The finished assets are **committed** —
including the foreground-service demonstration video that [the declaration](docs/PLAY_RELEASE_CHECKLIST.md) including the foreground-service demonstration video that [the declaration](docs/PLAY_RELEASE_CHECKLIST.md)
links to — and Gitea serves them without a login (`…/raw/branch/main/store-assets/…`), so pushing links to — and Gitea serves them without a login, so pushing is the deployment step:
is the deployment step and that URL is the link itself. `…/src/branch/main/store-assets/…` is the page a browser opens (and the shape Play's video link
takes, because a `/raw/` URL carries a `default-src 'none'` CSP that stops a video playing inline),
`…/raw/branch/main/…` is the bytes.
User-visible changes are tracked in [CHANGELOG.md](CHANGELOG.md); the Play "What's new" User-visible changes are tracked in [CHANGELOG.md](CHANGELOG.md); the Play "What's new"
text you type per release is separate and lives only in the Play Console. text you type per release is separate and lives only in the Play Console.
+28 -13
View File
@@ -283,23 +283,38 @@ action except the items marked **[code]**.
CANCEL"*, 13–18 s) while the scan kept running behind it, and the finished results CANCEL"*, 13–18 s) while the scan kept running behind it, and the finished results
(*17 duplicate groups · 54 of 5,385 photos*) at the end. (*17 duplicate groups · 54 of 5,385 photos*) at the end.
**The link to paste** is the committed file's own URL — no login, no second host: **The link to paste** is the file's page in Gitea — what the repository's own *view* of
the file opens, and what plays it:
``` ```
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/store-assets/fgs-demo/foreground-service-demo.mp4 https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/store-assets/fgs-demo/foreground-service-demo.mp4
``` ```
Checked from outside this network: **200**, `Content-Type: video/mp4`, ⚠️ **Not the `/raw/` URL**, even though that is the file itself. Gitea answers raw
`Content-Length: 1847900` and `Content-Disposition: inline`, so a reviewer's browser plays responses with `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
it in the tab rather than prompting a download, and the served bytes are identical to the sandbox` — a deliberate guard against raw HTML and SVG — and that blocks the media load of a
recording (sha256 match). A `Range: bytes=0-99` request answers **206** with exactly those document that navigates straight to a video: the tab stays blank with no error, which is
100 bytes even though `Accept-Ranges` is not advertised, so seeking works and a player can exactly how this behaved in a browser. The same URL is fine as a *subresource*: the page
start without fetching the whole file first — which matters here because `screenrecord` above embeds `<video controls src="…/raw/branch/main/…mp4">`, which is why previewing the
writes the `moov` atom at the very end of the file. Two things to know before pasting: the file plays it and opening `/raw/` does not. Use `/raw/` for `curl` and downloads, never as
URL is pinned to `main`, so a re-shoot has to **overwrite the file in place** rather than the link in the form.
be added alongside it; and replacing the video at all means resubmitting the declaration.
(An unlisted YouTube upload of the same file works equally well if a link that survives Verified about the **page** URL: **200** anonymously, containing that `<video controls>`
future commits is preferred — but then it is a second place to keep in step.) element. Verified about the **raw** URL, from outside this network and unauthenticated:
**200**, `Content-Type: video/mp4`, `Content-Length: 1847900`, `Content-Disposition:
inline`, bytes sha256-identical to the recording, and `Range: bytes=0-99` → **206** with
exactly those 100 bytes although `Accept-Ranges` is not advertised (which matters because
`screenrecord` writes the `moov` atom at the very end of the file). Two things to know
before pasting: the URL is pinned to `main`, so a re-shoot has to **overwrite the file in
place** rather than be added alongside it; and replacing the video at all means resubmitting
the declaration.
If a link with no Gitea chrome around the player is preferred later, there are two ways and
neither is needed for the declaration: an unlisted YouTube upload of the same file, or a
rule on the Gitea host's Caddy proxy dropping the header for this one path
(`@fgsdemo path …/store-assets/fgs-demo/*.mp4` plus `header_down @fgsdemo
-Content-Security-Policy`) — **scoped to `store-assets/` only**, since that header is what
makes raw HTML and SVG safe to serve. Neither was tried from here.
Both flags exist because this take found two traps worth knowing before a re-shoot: Both flags exist because this take found two traps worth knowing before a re-shoot:
+1 -1
View File
@@ -29,7 +29,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|---|---| |---|---|
| ~~Large-library scan stall~~ ✅ **fixed — verified on device** | The 5,363-photo library used to freeze at *“74 / 100 · Comparing file signatures…”* (a **blocked syscall**: zero CPU, workers sleeping). `BoundedCall` now abandons a stuck provider call **and** trips a stall breaker after three consecutive timeouts. **Verified on the real test device (Android 12 / MIUI): the scan completes — 20.2 s cold, 11.0 s warm, 15 groups, 6 unreadable — where it used to hang.** Covered by `BoundedCallTest` and `ScanEngineEndToEndTest` | | ~~Large-library scan stall~~ ✅ **fixed — verified on device** | The 5,363-photo library used to freeze at *“74 / 100 · Comparing file signatures…”* (a **blocked syscall**: zero CPU, workers sleeping). `BoundedCall` now abandons a stuck provider call **and** trips a stall breaker after three consecutive timeouts. **Verified on the real test device (Android 12 / MIUI): the scan completes — 20.2 s cold, 11.0 s warm, 15 groups, 6 unreadable — where it used to hang.** Covered by `BoundedCallTest` and `ScanEngineEndToEndTest` |
| ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) | | ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) |
| **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it, [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 carries all four things the form asks for (description, defer/interrupt impact, use case **Local processing: Other**, video link), and the **video now exists**: recorded on the test device on 2026-10-14 with `INSTALL=0 WARMUP=0 tools/record-fgs-demo.sh`, then verified frame by frame rather than assumed — intro at 0 s, the scan starting, **~6 s of the shade with the live "Finding duplicate photos / Looking for visually similar photos… / CANCEL" notification while the scan kept running**, and the results at the end. It is committed, and that is what published it: Gitea serves the raw file with `Content-Type: video/mp4` and `Content-Disposition: inline`, verified by hash from outside the network, so pasting that URL into the declaration is all that is left. Two details of the take are recorded in step 5 because they each cost an attempt: a *warm* scan is 5.4 s, shorter than the shade takes to open, so `WARMUP=0` films the cold one; and MIUI refuses `adb install` outright, so `INSTALL=0` records with the build already on the device. On a KVM-less emulator the same video cannot be made at all: every scan start dies as `am_crash … ForegroundServiceDidNotStartInTimeException` → *Force finishing activity* with no `am_kill`, i.e. emulation is too slow to reach `startForeground()` inside Android's budget (QEMU's `-icount` clock dilation made it work once, not reproducibly) | | **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it, [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 carries all four things the form asks for (description, defer/interrupt impact, use case **Local processing: Other**, video link), and the **video now exists**: recorded on the test device on 2026-10-14 with `INSTALL=0 WARMUP=0 tools/record-fgs-demo.sh`, then verified frame by frame rather than assumed — intro at 0 s, the scan starting, **~6 s of the shade with the live "Finding duplicate photos / Looking for visually similar photos… / CANCEL" notification while the scan kept running**, and the results at the end. It is committed, and that is what published it — the link is the file's **page** in Gitea (`/src/branch/main/…`), which plays it anonymously. The `/raw/` URL is deliberately not the link: Gitea sends `Content-Security-Policy: default-src 'none'` with raw responses, so a browser navigating to the video sits on a blank tab even though the bytes are perfect. Pasting the page URL into the declaration is all that is left. Two details of the take are recorded in step 5 because they each cost an attempt: a *warm* scan is 5.4 s, shorter than the shade takes to open, so `WARMUP=0` films the cold one; and MIUI refuses `adb install` outright, so `INSTALL=0` records with the build already on the device. On a KVM-less emulator the same video cannot be made at all: every scan start dies as `am_crash … ForegroundServiceDidNotStartInTimeException` → *Force finishing activity* with no `am_kill`, i.e. emulation is too slow to reach `startForeground()` inside Android's budget (QEMU's `-icount` clock dilation made it work once, not reproducibly) |
| **Remaining Play paperwork** | Console actions only, no code: the **first upload + Play App Signing enrolment**, the **hosted privacy policy URL** (the working copy is now published to Gitea as [`fishfacegit/foto-duplicate-finder`](https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder), so the policy renders at a public, login-free URL and only the Console paste is left), **Data safety**, the **Photo/video permissions** declaration, the **content rating / audience / app access / ads / export** questions, and the **listing copy + the three screenshot sets** (phone, 7-inch and 10-inch, all captured), plus the **paid-app setup**: a payments profile with tax rates, the €2.99 price and the 14-day €1.49 launch sale on *Products > App pricing* — the price must be set before the first upload, because free → paid is not possible afterwards (M9). Keystore, `versionCode`, listing copy, graphics, permission audit and the signed `.aab` (`sha256 f8e6a29a…`) are all in place; rollout goes internal → closed → production. Step-by-step: [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) | | **Remaining Play paperwork** | Console actions only, no code: the **first upload + Play App Signing enrolment**, the **hosted privacy policy URL** (the working copy is now published to Gitea as [`fishfacegit/foto-duplicate-finder`](https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder), so the policy renders at a public, login-free URL and only the Console paste is left), **Data safety**, the **Photo/video permissions** declaration, the **content rating / audience / app access / ads / export** questions, and the **listing copy + the three screenshot sets** (phone, 7-inch and 10-inch, all captured), plus the **paid-app setup**: a payments profile with tax rates, the €2.99 price and the 14-day €1.49 launch sale on *Products > App pricing* — the price must be set before the first upload, because free → paid is not possible afterwards (M9). Keystore, `versionCode`, listing copy, graphics, permission audit and the signed `.aab` (`sha256 f8e6a29a…`) are all in place; rollout goes internal → closed → production. Step-by-step: [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) |
| ~~Store screenshots need one re-shoot~~ ✅ **done — verified on device** | The old captures showed the *Photo access needed* banner (an app-op is not a grant) and `03-results.png` showed the developer's own library. After a real grant the set was re-shot over the generated demo library: **01-empty** (no tap needed — results and selection live in `ScannerViewModel`, so force-stop + relaunch returns to the empty state with the grant still in place), **02-scanning** (`5353 / 5385`), **03-results** (**17 groups, 54 of 5385 photos, 83.5 MB reclaimable**; the headline group's 43.1 MB out-ranks the device's own 39.8 MB, so no real photo is in frame) and **04-options**. Captures are taken by watching for UI **text** changes, because MIUI refuses synthetic taps (`INJECT_EVENTS`). The demo library is deleted from the test device afterwards — MediaStore **5,386 → 5,364** rows, and the 22 stale rows it leaves in the hash index are pruned by `PhotoIndexStore.pruneTo` on the next scan. Deliberately not captured: the staged state and the system confirmation dialog, because staging clears the demo group and would put real photos back in frame. Details: [STORE_LISTING](STORE_LISTING.md) | | ~~Store screenshots need one re-shoot~~ ✅ **done — verified on device** | The old captures showed the *Photo access needed* banner (an app-op is not a grant) and `03-results.png` showed the developer's own library. After a real grant the set was re-shot over the generated demo library: **01-empty** (no tap needed — results and selection live in `ScannerViewModel`, so force-stop + relaunch returns to the empty state with the grant still in place), **02-scanning** (`5353 / 5385`), **03-results** (**17 groups, 54 of 5385 photos, 83.5 MB reclaimable**; the headline group's 43.1 MB out-ranks the device's own 39.8 MB, so no real photo is in frame) and **04-options**. Captures are taken by watching for UI **text** changes, because MIUI refuses synthetic taps (`INJECT_EVENTS`). The demo library is deleted from the test device afterwards — MediaStore **5,386 → 5,364** rows, and the 22 stale rows it leaves in the hash index are pruned by `PhotoIndexStore.pruneTo` on the next scan. Deliberately not captured: the staged state and the system confirmation dialog, because staging clears the demo group and would put real photos back in frame. Details: [STORE_LISTING](STORE_LISTING.md) |
| ~~Tablet store screenshots outstanding~~ ✅ **done — captured on AVDs** | Play has a **7-inch tablet** and a **10-inch tablet** screenshot slot of its own (asking for **≥ 4** screenshots on large screens). Both are now captured on AOSP Android 12 AVDs — `Nexus 7 2013` (1200×1920, `sw600dp`) and `medium_tablet` (1600×2560, `sw800dp`) — because this machine exposes no `/dev/kvm`: they ran in **pure software emulation** (`-accel off -no-window -gpu swiftshader_indirect`), ≈3 min to boot, with `hide_error_dialogs` on so SystemUI's ANR dialog could not sit in frame. Four screens per tablet over the same generated demo library: empty, scanning (20 / 22), results (**2 groups · 16 of 22 photos · 43.7 MB reclaimable**, 39.7 s / 53.1 s) and Options. `tools/make-store-screenshots.sh` is form-factor aware (`FORM_FACTOR=phone\|tablet-7in\|tablet-10in`) and the tablet form factors crop **0 px** of chrome, because a tablet frame is already inside Play's 2:1 cap. Recipe: [STORE_LISTING](STORE_LISTING.md) | | ~~Tablet store screenshots outstanding~~ ✅ **done — captured on AVDs** | Play has a **7-inch tablet** and a **10-inch tablet** screenshot slot of its own (asking for **≥ 4** screenshots on large screens). Both are now captured on AOSP Android 12 AVDs — `Nexus 7 2013` (1200×1920, `sw600dp`) and `medium_tablet` (1600×2560, `sw800dp`) — because this machine exposes no `/dev/kvm`: they ran in **pure software emulation** (`-accel off -no-window -gpu swiftshader_indirect`), ≈3 min to boot, with `hide_error_dialogs` on so SystemUI's ANR dialog could not sit in frame. Four screens per tablet over the same generated demo library: empty, scanning (20 / 22), results (**2 groups · 16 of 22 photos · 43.7 MB reclaimable**, 39.7 s / 53.1 s) and Options. `tools/make-store-screenshots.sh` is form-factor aware (`FORM_FACTOR=phone\|tablet-7in\|tablet-10in`) and the tablet form factors crop **0 px** of chrome, because a tablet frame is already inside Play's 2:1 cap. Recipe: [STORE_LISTING](STORE_LISTING.md) |
+9 -5
View File
@@ -41,15 +41,19 @@ python3 tools/make-demo-photos.py # synthetic photo li
``` ```
Committing `store-assets/` is also what *publishes* it: Gitea serves every committed file without a Committing `store-assets/` is also what *publishes* it: Gitea serves every committed file without a
login, at login, and pushing the commit **is** the deployment step — there is no separate upload, so a
re-shot screenshot or re-recorded video goes live the moment it is pushed. There are two URL shapes,
and which one is right depends on what does the reading:
``` ```
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/<path> https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/raw/branch/main/<path> the bytes — curl, downloads
https://gitea.softwareapp-hb.de/fishfacegit/foto-duplicate-finder/src/branch/main/<path> a page — what a browser opens
``` ```
so pushing the commit **is** the deployment step — there is no separate upload, and a re-shot For media that distinction is not cosmetic: Gitea sends `Content-Security-Policy: default-src
screenshot or re-recorded video goes live as soon as it is pushed. Play's foreground-service 'none'` with `/raw/` responses, so a video opened at a raw URL shows a blank tab, while the same file
declaration takes its video link from exactly there; the URL is written out in plays on its `src` page. Play's foreground-service declaration therefore takes the **page** URL as
its video link; the reasoning and what was verified about both URLs are in
[PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5. [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5.
| Asset | Play's spec | Result | Source | | Asset | Play's spec | Result | Source |