feat: record the foreground-service video Play's declaration asks for

The App content > Foreground service permissions form does not just want the justification that
was already written down. Per declared type it asks for four things, one of which is "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."

tools/record-fgs-demo.sh records exactly that journey on a device or emulator: the app on its
intro, the user tapping Start scan, Home, the ongoing notification visible in the shade while
the scan keeps running, and back into the app for the results. It loads the generated demo
library first so no real photo is in frame, writes a checkpoint PNG at each milestone so the
recording can be checked without decoding it, and takes the library off the device afterwards.
The output is git-ignored, like the screenshots.

Four things the script has to get right, each learned by getting it wrong first:

- bounds="[x1,y1][x2,y2]" cannot be parsed by stripping everything but digits: the "][ "
  between the corners goes too, which glued y1 to x2 and produced coordinates like
  "976 723061" - a tap that lands nowhere and reports no error at all.
- No single text matcher works: an exact match never finds the results card ("2 duplicate
  groups"), while a substring match lets "Scan" match both "Start scan" and "Scanned in
  39.7 s". Both matchers exist and each call site uses the right one.
- A finished scan leaves the app on its results screen, where the intro's button no longer
  exists - tapping those stale coordinates silently does nothing.
- `set -e` plus start="$(failing command)" aborts a run with no message whatsoever, which
  cost a whole debugging cycle.

Checklist step 5 now carries all four fields with paste-ready text: the description, the user
impact if the scan is deferred or interrupted (a scan is not deferrable work - the user is
waiting for a result, so there is no WorkManager/JobScheduler fallback), the use case
(Local processing: Other, the only dataSync option that describes a local, user-initiated
scan), and the video link.

Recording the take on this machine is not reliable, and the docs say so rather than pretending
otherwise, because both failures are platform-level rather than app-level:

- a cold app can miss Android's 5-second startForeground deadline and be killed, giving
  ForegroundServiceDidNotStartInTimeException -> Force finishing activity, i.e. the launcher
  where the scan should have started. The warm-up phase fixes it (a process that has started
  the service once never misses), but the fallback path of restarting the app gives the warmth
  back again, which is why the recorded scan is now triggered from the results screen's top-bar
  "Scan" instead of the intro's button.
- the app also died mid-scan on the emulator, with the launcher on screen afterwards and no
  exception of its own anywhere in logcat. No amount of warming fixes that one.

Neither reproduces on the real test device, where the same 22-photo library scans without
incident, so the take belongs on the phone. The script is the checklist for what it must show.
This commit is contained in:
2026-10-14 11:13:21 +02:00
parent f45e3b79ab
commit 1ce2f2e1f4
4 changed files with 326 additions and 7 deletions
+3
View File
@@ -27,3 +27,6 @@ fixtures/
store-assets/raw-screenshots/ store-assets/raw-screenshots/
demo-photos/ demo-photos/
store-assets/screenshots/ 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/
+48 -6
View File
@@ -234,13 +234,55 @@ action except the items marked **[code]**.
| `ScanService.kt` | `ServiceCompat.startForeground(…, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)` | | `ScanService.kt` | `ServiceCompat.startForeground(…, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)` |
| `ScanService.kt` | `onTimeout()` stops the scan at Android 15's 6-hour `dataSync` cap | | `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 The declaration page asks for **four** things per type, not one. Go to
**Data sync**, and paste: **App content › Foreground service permissions**:
> The app finds duplicate photos on the device. The user starts a scan and may leave 1. **Description** of the app functionality using the type:
> 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 > The app finds duplicate photos on the device. The user starts a scan and may leave
> index of file hashes. It performs no network access and transfers no data. > 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 declaration is **per type**: if a new foreground-service type is ever added to
the manifest, it has to be declared here too. the manifest, it has to be declared here too.
+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; 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) | | **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) |
+274
View File
@@ -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 <bounds attribute value> -> "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 <text> -> "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 <text> -> 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 <text> <seconds> -> "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 <text> <seconds> -> 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() { # <label> — one PNG per milestone, so the video can be verified without decoding it
"${adb[@]}" exec-out screencap -p > "$out/checkpoint-$1.png" 2>/dev/null || true
printf ' checkpoint-%s.png %s\n' "$1" "$(identify -format '%wx%h' "$out/checkpoint-$1.png" 2>/dev/null || echo '?')"
}
# --------------------------------------------------------------- device and app setup
say "Device $serial"
"${adb[@]}" install -r "$apk" | tail -1
# The video is about the foreground service, not about a permission prompt, so grant photo access
# up front - the "Photo access needed" banner would otherwise be the first thing a reviewer sees.
"${adb[@]}" shell pm grant "$pkg" android.permission.READ_EXTERNAL_STORAGE 2>/dev/null || true
say "Demo library"
python3 "$here/make-demo-photos.py" >/dev/null
# Wipe and re-index before pushing. Pushing over a folder MediaStore has already indexed can leave
# a second row per file, and the video would then show a library made entirely of exact duplicates.
"${adb[@]}" shell rm -rf /sdcard/DCIM/foto-dup-demo
"${adb[@]}" shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo >/dev/null 2>&1 || true
sleep 2
"${adb[@]}" push "$repo/demo-photos/foto-dup-demo" /sdcard/DCIM/foto-dup-demo 2>&1 | tail -1
"${adb[@]}" shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo >/dev/null
sleep 2
local_files="$(find "$repo/demo-photos/foto-dup-demo" -name '*.jpg' | wc -l)"
# `content query` prints "No result found." rather than nothing when the table is empty, so count
# the rows instead of the lines of output.
indexed="$("${adb[@]}" shell content query --uri content://media/external/images/media --projection _id 2>/dev/null \
| grep -c '^Row:' || true)"
indexed="${indexed:-0}"
printf ' %s images indexed, %s in the generated library\n' "$indexed" "$local_files"
if [ "$indexed" -ne "$local_files" ]; then
echo "warn: device library does not match the generated one - the results screen will not match the other captures" >&2
fi
say "Clearing stale notifications, so the shade shows only this app"
"${adb[@]}" shell cmd statusbar expand-notifications >/dev/null 2>&1 || true
sleep 3
if clear_all="$(node_centre 'Clear all')"; then
"${adb[@]}" shell input tap $clear_all
printf ' tapped Clear all at %s\n' "$clear_all"
fi
"${adb[@]}" shell cmd statusbar collapse >/dev/null 2>&1 || true
sleep 2
# --------------------------------------------------------------------- warm-up
#
# On a software-emulated device (no /dev/kvm) a *cold* app can miss Android's five-second
# startForegroundService() deadline. The platform then kills the process with
# ForegroundServiceDidNotStartInTimeException and force-finishes the activity, so the launcher
# appears where the scan should have started. That reproduced every single time on a freshly
# started process and never on one that had already run a scan, so: run a scan, cancel it, and only
# then record. Cancelling early also leaves the hash index half-built, which keeps the recorded scan
# long enough for the notification to be on screen.
say "Warming the app up (a cold process can miss the FGS deadline - see the note in this script)"
attempt=0
while [ "$attempt" -lt 3 ]; do
attempt=$(( attempt + 1 ))
"${adb[@]}" shell am force-stop "$pkg"
"${adb[@]}" shell am start -n "$activity" >/dev/null
if ! start="$(wait_for 'Start scan' 120)"; then
echo "error: the empty state never appeared - right APK installed? photo access granted?" >&2
exit 3
fi
"${adb[@]}" shell input tap $start
# dumpsys only while the service starts: every `uiautomator dump` spawns a JVM on the device and
# competes for exactly the CPU that the five-second deadline is measured against.
for _ in $(seq 1 10); do
if scan_running; then break; fi
sleep 3
done
if scan_running; then
printf ' attempt %s: scan started - this process has now met the deadline once\n' "$attempt"
break
fi
printf ' attempt %s: the service never started\n' "$attempt"
done
# Let the warm-up scan run to *completion* instead of cancelling it: cancelling does not lead back to
# the intro, and restarting the app to get there throws away exactly the warmth that keeps the
# deadline met - which is how the first recordings ended up with the platform killing the app on
# camera. So the recorded scan is triggered the way a returning user triggers one: the top bar's
# "Scan", on the results screen.
say "Letting the warm-up scan finish"
if ! wait_for 'duplicate group' 300 >/dev/null; then
wait_for 'No duplicates found' 20 >/dev/null \
|| echo "warn: the warm-up scan never reached a result screen" >&2
fi
checkpoint 0-warmup-results
say "Recording (screenrecord, at most ${recording_seconds}s)"
"${adb[@]}" shell rm -f "$remote"
# 540x1200 at 2M, not 720x1600 at 8M: the encoder runs on the guest CPU as well, and an expensive
# recording is precisely what pushes the app past the five-second deadline.
"${adb[@]}" shell screenrecord --size 540x1200 --bit-rate 2M --time-limit "$recording_seconds" "$remote" &
recorder_pid=$!
sleep 3
# The trigger. This is the step the declaration video exists to show. Exact match, because the
# results card's "Scanned in 39.7 s" also contains "Scan".
if ! scan_button="$(wait_for_exact 'Scan' 30)"; then
echo "error: no Scan button on screen to trigger the scan with" >&2
exit 3
fi
printf ' Scan (top bar) at %s\n' "$scan_button"
"${adb[@]}" shell input tap $scan_button
sleep 8 # deliberately dump-free: this is the deadline window
if ! scan_running; then
printf ' the service did not start; the platform said:\n'
"${adb[@]}" logcat -d -t 300 2>/dev/null \
| grep -m1 -E 'ForegroundServiceDidNotStartInTimeException' || true
echo "warn: no scan running - the video will not show the service" >&2
else
checkpoint 1-scanning
fi
printf ' scan running: %s\n' "$(scan_running && echo yes || echo no)"
# Away from the app: from here on the service is what keeps the scan alive.
sleep 3
"${adb[@]}" shell input keyevent KEYCODE_HOME
sleep 3
"${adb[@]}" shell cmd statusbar expand-notifications
sleep 6
checkpoint 2-notification
printf ' notifications posted by the app: %s\n' "$(notifications_from_app)"
# Back into the app for the payoff, waiting for the scan to have produced a result.
"${adb[@]}" shell cmd statusbar collapse
"${adb[@]}" shell am start -n "$activity" >/dev/null
if wait_for 'duplicate group' 240 >/dev/null || wait_for 'No duplicates found' 10 >/dev/null; then
sleep 3
checkpoint 3-results
fi
say "Finalising the recording"
# SIGINT-ing the *device* process is what writes the moov atom; killing the local adb client would
# leave screenrecord orphaned on the device and the pulled file unplayable.
"${adb[@]}" shell 'pidof screenrecord | xargs -r kill -2' || true
wait "$recorder_pid" 2>/dev/null || true
sleep 2
"${adb[@]}" pull "$remote" "$out/foreground-service-demo.mp4" 2>&1 | tail -1
"${adb[@]}" shell rm -f "$remote"
say "Taking the demo library back off the device"
"${adb[@]}" shell rm -rf /sdcard/DCIM/foto-dup-demo
"${adb[@]}" shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo >/dev/null || true
say "Done"
if command -v ffprobe >/dev/null; then
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 \
"$out/foreground-service-demo.mp4" | awk '{printf " recorded %.1f s\n", $1}' || true
fi
echo ' Upload it (an unlisted YouTube link works) and paste the link into the declaration form.'