diff --git a/.gitignore b/.gitignore index 2a51cfe..30f8b27 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ fixtures/ store-assets/raw-screenshots/ demo-photos/ store-assets/screenshots/ +# Recorded demonstrations for Play's declarations (a picture of the demo library, like the +# screenshots, so it is built rather than stored) +store-assets/fgs-demo/ diff --git a/docs/PLAY_RELEASE_CHECKLIST.md b/docs/PLAY_RELEASE_CHECKLIST.md index 07e653a..a4a5260 100644 --- a/docs/PLAY_RELEASE_CHECKLIST.md +++ b/docs/PLAY_RELEASE_CHECKLIST.md @@ -234,13 +234,55 @@ action except the items marked **[code]**. | `ScanService.kt` | `ServiceCompat.startForeground(…, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)` | | `ScanService.kt` | `onTimeout()` stops the scan at Android 15's 6-hour `dataSync` cap | - In the Console go to **App content › Foreground service permissions**, select - **Data sync**, and paste: + The declaration page asks for **four** things per type, not one. Go to + **App content › Foreground service permissions**: - > The app finds duplicate photos on the device. The user starts a scan and may leave - > the app while it runs; the foreground service keeps that user-initiated scan alive - > and shows its progress. The scan reads the photo library and writes a small local - > index of file hashes. It performs no network access and transfers no data. + 1. **Description** of the app functionality using the type: + + > The app finds duplicate photos on the device. The user starts a scan and may leave + > the app while it runs; the foreground service keeps that user-initiated scan alive + > and shows its progress. The scan reads the photo library and writes a small local + > index of file hashes. It performs no network access and transfers no data. + + 2. **User impact if the task is deferred or interrupted.** A scan is not deferrable work — + the user is watching for a result — so there is no WorkManager/JobScheduler fallback: + + > If the scan were deferred, tapping "Start scan" would appear to do nothing and the + > user would have to keep the app open for the work to begin at all. If it were + > interrupted, the user would lose the progress of a whole-library comparison and + > have to start again. Only reads are involved, so an interruption cannot lose or + > corrupt data; the service stops as soon as the scan finishes or is cancelled, and + > it is held only for the duration of the scan. + + 3. **Use case**: **Local processing: Other** — *"Use for specifically user-initiated work + and not for regular system or server-initiated tasks."* The other `dataSync` choices + are network transfers (backup/restore, upload/download) and *Local processing: Import + or export*, none of which describes a local, user-initiated duplicate scan. + + 4. **A link to a video** demonstrating the feature — *"The video should demonstrate the + steps the user needs to take in your app in order to trigger the feature."* Record it + with `./tools/record-fgs-demo.sh`, which drives a device through exactly that journey + (empty state → *Start scan* → Home → the notification visible in the shade while the + scan keeps running → results) and writes + `store-assets/fgs-demo/foreground-service-demo.mp4` (git-ignored, like the + screenshots). Put the file where a reviewer can open it without signing in — an + unlisted YouTube link is the usual choice — and paste that link in. Replacing the + video later means resubmitting the declaration. + ⚠️ **Record it on a real device.** The capture proved unreliable on this machine's + software-emulated AVD (no KVM), and both failures are platform-level rather than + app-level: + + - A **cold** app can miss Android's 5-second `startForeground` deadline and get killed + (`ForegroundServiceDidNotStartInTimeException` → *Force finishing activity*), so the + launcher appears where the scan should have started. The script's warm-up phase gets a + process past that — a process that has already started the service once succeeds — but + - the app also died **mid-scan** on the emulator during testing, and the launcher was on + screen afterwards with no exception from the app in logcat at all. A warmer process + does not fix that one. + + Neither failure has been seen on the real test device, where the same 22-photo library + scans without incident. So treat `tools/record-fgs-demo.sh` as the checklist for the + journey it records, and run it against the phone. The declaration is **per type**: if a new foreground-service type is ever added to the manifest, it has to be declared here too. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 911b41a..394a9dc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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` | | ~~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; the ready-to-paste justification is in [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 — this is a Console action, not a code change | +| **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it, and [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 now carries all four things the form asks for: the description, the user impact if the scan is deferred or interrupted, the use case (**Local processing: Other** — the only `dataSync` option that fits a local, user-initiated scan) and the **video link** Play requires. `tools/record-fgs-demo.sh` records that video — empty state → *Start scan* → Home → the notification in the shade while the scan continues → results — into the git-ignored `store-assets/fgs-demo/`; hosting it and pasting the link is still a manual step. The take itself belongs on a real device: on this machine's KVM-less AVD the app missed Android's 5-second `startForeground` deadline when cold (`ForegroundServiceDidNotStartInTimeException` → *Force finishing activity*) and died mid-scan once with no exception of its own in logcat, neither of which the script's warm-up phase fully removes, and neither of which reproduces on the phone | | **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) | | ~~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) | diff --git a/tools/record-fgs-demo.sh b/tools/record-fgs-demo.sh new file mode 100755 index 0000000..e074d71 --- /dev/null +++ b/tools/record-fgs-demo.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# +# Records the demonstration video that Google Play's foreground-service declaration asks for. +# +# ./tools/record-fgs-demo.sh [serial] [output-dir] # default: first adb device, ./store-assets/fgs-demo +# +# Play requires, per declared foreground-service type, "a link to a video demonstrating each +# foreground service feature. The video should demonstrate the steps the user needs to take in +# your app in order to trigger the feature." So this records an end-to-end *user journey*, not a +# screen of a progress bar: +# +# app open on the empty state -> user taps Start scan -> user presses Home -> the ongoing +# notification is visible in the shade while the scan keeps running -> back into the app for +# the finished results +# +# A foreground service only exists while work is running, so this needs a real device or an +# emulator and something to scan. `tools/make-demo-photos.py` supplies the library, so no real +# photo is ever in the frame, and the library is deleted from the device again afterwards. +# +# Two failure modes this script works around, both learned the hard way on a software-emulated AVD +# (no /dev/kvm, so everything below is slower than it looks): +# +# 1. `input tap` can land before the app is interactive and do nothing at all. Every tap is +# therefore preceded by a `uiautomator dump` for the *live* coordinates and followed by a +# check that the expected next screen appeared, with one retry. +# 2. A finished scan leaves the app on its results screen, where "Start scan" no longer exists - +# tapping those old coordinates silently does nothing. The recording therefore always starts +# from `am force-stop` + `am start`, i.e. the documented empty state. +# +# It also writes a checkpoint PNG at each milestone, so the recording can be checked without +# decoding the video. `ffprobe` is used for the duration when it is installed. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(dirname "$here")" +pkg="de.softwareapp_hb.fotodup.finder.debug" +activity="$pkg/de.softwareapp_hb.fotodup.finder.MainActivity" +apk="${APK:-$repo/app/build/outputs/apk/debug/app-debug.apk}" +serial="${1:-$(adb devices | awk 'NR==2 {print $1}')}" +out="${2:-$repo/store-assets/fgs-demo}" +remote=/data/local/tmp/fgs-demo.mp4 +recording_seconds="${RECORDING_SECONDS:-150}" + +if [ -z "$serial" ]; then + echo "usage: $0 [serial] [output-dir]" >&2 + echo "error: no adb device or emulator attached" >&2 + exit 2 +fi +adb=(adb -s "$serial") +mkdir -p "$out" + +say() { printf '\n== %s\n' "$*"; } + +# --------------------------------------------------------------------- UI helpers + +# bounds_to_centre -> "x y" +bounds_to_centre() { + local x1 y1 x2 y2 + # bounds is "[x1,y1][x2,y2]". Stripping everything but digits and commas is not enough: the + # "][ " between the two corners disappears too, which glued y1 to x2 and produced coordinates + # like "976 723061" - a tap that lands nowhere, with no error to show for it. + read -r x1 y1 x2 y2 <<< "$(printf '%s' "$1" \ + | sed -E 's/.*\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\].*/\1 \2 \3 \4/')" + echo "$(( (x1 + x2) / 2 )) $(( (y1 + y2) / 2 ))" +} + +node_by_pattern() { + local bounds + bounds="$(ui_dump | tr '>' '\n' | grep -i -m1 "$1" \ + | grep -o 'bounds="\[[0-9]*,[0-9]*\]\[[0-9]*,[0-9]*\]"' | head -1 || true)" + [ -n "$bounds" ] || return 1 + bounds_to_centre "$bounds" +} + +# node_centre -> "x y" for the first node whose text contains it. Substring match on purpose: +# the screens label themselves ("Start scan", "Cancel"), but the results card says "2 duplicate +# groups", which an exact match would never find. Anchors must not be passed in here - "^Cancel$" +# would be compared literally against the attribute text. +node_centre() { node_by_pattern "text=\"[^\"]*$1[^\"]*\""; } + +# node_centre_exact -> the same, but only for a node whose text *is* this. Needed for the +# top bar's "Scan", which must not match the intro's "Start scan". +node_centre_exact() { node_by_pattern "text=\"$1\""; } + +ui_dump() { + "${adb[@]}" shell 'uiautomator dump /data/local/tmp/ui.xml >/dev/null 2>&1; cat /data/local/tmp/ui.xml' +} + +# wait_for -> "x y" once a node whose text contains it appears. +wait_for() { + local deadline=$(( SECONDS + $2 )) coords + while [ "$SECONDS" -lt "$deadline" ]; do + if coords="$(node_centre "$1")"; then + echo "$coords" + return 0 + fi + sleep 3 + done + return 1 +} + +# wait_for_exact -> the same for an exact text match. +wait_for_exact() { + local deadline=$(( SECONDS + $2 )) coords + while [ "$SECONDS" -lt "$deadline" ]; do + if coords="$(node_centre_exact "$1")"; then + echo "$coords" + return 0 + fi + sleep 3 + done + return 1 +} + +scan_running() { "${adb[@]}" shell dumpsys activity services 2>/dev/null | grep -q -i ScanService; } + +notifications_from_app() { + "${adb[@]}" shell dumpsys notification --noredact 2>/dev/null \ + | grep -c 'NotificationRecord(.*fotodup' || true +} + +checkpoint() { #