179bc2cc33
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.
303 lines
14 KiB
Bash
Executable File
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.'
|
|
|