Files
foto-duplicate-finder/tools/record-fgs-demo.sh
T
SoftwareApp 179bc2cc33 fix: prove the FGS video's scan start by notification, not by the service record
The script could report "scan started" on runs where the app was being killed, which sent me
chasing a "mid-scan death" that never happened. The mistake was the signal: dumpsys activity
services lists a service *record* from the moment startForegroundService() is called, so it says
nothing about whether startForeground() was reached - and that gap is exactly what the platform's
deadline measures. logcat's events buffer shows what really happened on every attempt:

  am_crash: [..., android.app.ForegroundServiceDidNotStartInTimeException,
             Context.startForegroundService() did not then call
             Service.startForeground(): .../ScanService]
  am_proc_died: [...]

No am_kill, no low-memory record: not memory pressure, and not a crash in the app's own code. The
script now waits for the app's notification, which only exists once startForeground() has been
called, and prints that am_crash line when the service never starts, so a failure is never
mistaken for a success again.

Also in the script:

- let the emulator settle before the run (first-boot work competes for the same CPU) and give the
  process time to load its classes before asking it to start a service inside the budget, both
  overridable as SETTLE_SECONDS and WARM_SECONDS,
- give up with a non-zero exit and an explicit "record this on a real device" message rather than
  carrying on to record a video whose scan never started,
- read the events buffer rather than the main one when reporting the reason.

Verdict for the docs, corrected: this capture cannot be done on a KVM-less emulator, and the
previous "second failure mode" was this one misread. The last crash landed on a process that had
been alive and idle for over a minute, so no amount of warming fixes it - while on the phone the
same build scans 5,363 photos in 20 s with the notification up the whole time. The take belongs on
the phone; the script is the checklist for what it has to show.
2026-10-14 11:39:49 +02:00

303 lines
14 KiB
Bash
Executable File

#!/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
}
notifications_from_app() {
"${adb[@]}" shell dumpsys notification --noredact 2>/dev/null \
| grep -c 'NotificationRecord(.*fotodup' || true
}
# The signal that actually matters. A service *record* appears as soon as startForegroundService() is
# called, so it does not prove that startForeground() succeeded - and that five-second gap is exactly
# what kills this app on a slow emulator. The first version of this script used the record as its
# "scan started" evidence and therefore reported success on runs where the app was being killed
# (logcat's events buffer shows am_crash ForegroundServiceDidNotStartInTimeException moments later).
# The app's notification only exists once startForeground() has been called, so wait for that.
fgs_started() { [ "$(notifications_from_app)" != "0" ]; }
# Secondary signal, for logging only.
scan_running() { "${adb[@]}" shell dumpsys activity services 2>/dev/null | grep -q -i ScanService; }
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
#
# The one thing that reliably kills this capture on a software-emulated device (no /dev/kvm) is the
# platform's five-second startForegroundService() deadline: the app is killed with
# ForegroundServiceDidNotStartInTimeException and the activity is force-finished, so the launcher
# appears where the scan should have started. logcat's events buffer names it:
#
# am_crash: [..., android.app.ForegroundServiceDidNotStartInTimeException,
# Context.startForegroundService() did not then call Service.startForeground(): ...]
# am_proc_died: [...]
#
# It is a question of CPU, not of the app: the same build starts the service immediately when the
# process has been alive for a while (classes loaded, JIT warm) and the emulator is not still doing
# first-boot work. So warm the app up first, and only start recording once a real scan has been
# confirmed - by the notification, for the reason given at fgs_started().
if [ "${SETTLE_SECONDS:-150}" != "0" ]; then
say "Letting the emulator settle (first-boot work competes for the same CPU)"
sleep "${SETTLE_SECONDS:-150}"
fi
say "Warming the app up (a cold process misses the FGS deadline - see the note in this script)"
attempt=0
while [ "$attempt" -lt 4 ]; do
attempt=$(( attempt + 1 ))
"${adb[@]}" shell am force-stop "$pkg"
"${adb[@]}" shell am start -n "$activity" >/dev/null
# Time for the process to load its classes before it is asked to start a service in under five
# seconds. This is the difference between the runs that worked and the ones that crashed.
sleep "${WARM_SECONDS:-75}"
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
started=no
# 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 12); do
if fgs_started; then started=yes; break; fi
sleep 3
done
if [ "$started" = yes ]; then
printf ' attempt %s: the service started (notification posted)\n' "$attempt"
break
fi
printf ' attempt %s: the service never started\n' "$attempt"
"${adb[@]}" logcat -b events -d -t 400 2>/dev/null | grep -m1 'am_crash' || true
done
if [ "$started" != yes ]; then
echo "error: no scan could be started on this emulator - record the video on a real device" >&2
exit 4
fi
# 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 ! fgs_started; then
printf ' the service did not start; the events buffer said:\n'
"${adb[@]}" logcat -b events -d -t 400 2>/dev/null | grep -m1 'am_crash' || true
echo "warn: no scan running - the video will not show the service" >&2
else
checkpoint 1-scanning
printf ' the service started (notification posted) on a process warmed by attempt %s\n' "$attempt"
fi
# 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.'