Files
hash-wallet/tools/demo/capture.sh
T
jwinterm c396e94b77 Add store capture harness: record real wallet flows into screenshots + video
Drives the real app through real flows on a device while recording the screen,
then cuts ONE recording into both deliverables — store screenshots and
marketing video — so a listing can't drift out of sync with its own trailer.

integration_test/demo/
  DemoSession adds what a passing test lacks: pacing (beat/hold/typeSlowly, so
  footage is watchable rather than a strobe), timing marks reported through
  binding.reportData, and seed guards. Scenarios reuse the existing 25 page
  robots rather than reimplementing navigation:
    core_store_set     dashboard, receive, send (composed, NEVER broadcast),
                       wallets, security — the store screenshot set
    onboarding_create  welcome -> PIN -> seed -> dashboard
    restore_from_seed  restore -> syncing
    swap_and_buy       Trocador quote, stopping BEFORE creating a trade; Buy
                       skipped when no provider key, matching what reviewers see

tools/demo/
  capture.sh   orchestrates boot/record/cut/shots/reel
  marks.py     maps timing marks onto the recording; cuts, extracts frames,
               builds manifests
  reel.py      branded master; reuses such_graphics video_transitions/video_qa
               when ~/src/such-graphics is present, degrades cleanly when not
               (this repo is public and must build without it)
  build-apk.sh local APK via the CI container image

Seed safety — the demo wallet is a real wallet with real funds:
  - seed comes from Vaultwarden at capture time, never the repo
  - guardNoSeedOnScreen() fails the run on a visible mnemonic, catching both
    the known seed and the generic shape of one (so freshly generated seeds
    are caught too)
  - unavoidable seed screens are wrapped in blindfold(); those spans are
    excised from the video with 0.6s padding, and screenshots honour the same
    padding
  - marks.py refuses to build a publishable master if any clip was cut without
    marks, since blindfold spans could not have been excised

Apple App Preview gets no touch indicators, captions, cards or music, and
reel.py enforces the 15-30s window rather than emitting an out-of-spec file.
Everything else keeps taps on, drawn by SurfaceFlinger via show_touches rather
than composited, so they look real.

Play sizing: the harness builds its own 1080x1920 AVD. The stock Medium Phone
AVD is 1080x2400 = 2.22:1 and Play rejects phone screenshots above 2:1.

Also gitignores android/app/upload-keystore.jks (and *.jks/*.keystore): the
Android workflow decodes ANDROID_KEYSTORE_BASE64 to exactly that path, so the
Play upload signing key was one `git add -A` away from being committed.
2026-07-31 15:25:29 -04:00

366 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Hash Bags capture harness — drive real wallet flows while recording screen.
#
# One recording produces BOTH deliverables: the video (trimmed and captioned
# from the scenario's timing marks) and the store screenshots (frames pulled at
# those same marks, at native device resolution). Shooting them separately is
# how listings end up inconsistent.
#
# tools/demo/capture.sh boot
# tools/demo/capture.sh record core_store_set
# tools/demo/capture.sh shots core_store_set
# tools/demo/capture.sh reel social
# tools/demo/capture.sh all
#
# Options:
# --target android|linux where to run (default: android)
# --overlays taps|none touch indicators (default: taps)
# --avd NAME emulator AVD (default: HashBags_Capture)
# --serial NAME adb serial (default: autodetect)
#
# OVERLAYS AND THE APPLE APP PREVIEW
# The `apple-preview` reel profile force-disables overlays and refuses to run
# with --overlays=taps. The App Preview must be the app as a user sees it,
# with no synthetic touch indicators composited on top. Everything else
# (social cuts, Play listing video, docs GIFs) keeps taps on by default,
# because there they genuinely help a viewer follow the interaction.
#
# SEED SAFETY
# Scenarios bracket every frame containing a mnemonic in a `blindfold` span
# and report those spans back through the driver. This script refuses to
# promote any master to the publishable directory while a blindfold span is
# still inside the cut. The demo wallet holds real funds — that guard is not
# optional and should not be edited out.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$HERE/../.." && pwd)"
# shellcheck source=lib/secrets.sh
source "$HERE/lib/secrets.sh"
demo_load_env "$HERE/demo.env"
IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}"
ANDROID_SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
ADB="$ANDROID_SDK/platform-tools/adb"
EMULATOR_BIN="$ANDROID_SDK/emulator/emulator"
TARGET="android"
OVERLAYS="taps"
AVD="${DEMO_AVD:-HashBags_Capture}"
SERIAL="${DEMO_SERIAL:-}"
# Build outputs live outside the repo, per the workstation conventions.
OUT_ROOT="${DEMO_OUT_ROOT:-$HOME/Build/hash-wallet/demo}"
RAW_DIR="$OUT_ROOT/raw" # untrimmed recordings + driver JSON
CUT_DIR="$OUT_ROOT/cut" # trimmed, blindfold-free clips
SHOT_DIR="$OUT_ROOT/screenshots" # store screenshots
PUB_DIR="$OUT_ROOT/publish" # finished masters — only safe material lands here
SCENARIOS=(core_store_set onboarding_create restore_from_seed swap_and_buy)
# On-device path for the in-flight recording.
DEV_CAPTURE=/data/local/tmp/hb-capture.mp4
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# arg parsing
# ---------------------------------------------------------------------------
CMD="${1:-help}"; shift || true
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--overlays) OVERLAYS="$2"; shift 2 ;;
--avd) AVD="$2"; shift 2 ;;
--serial) SERIAL="$2"; shift 2 ;;
-h|--help) CMD="help"; shift ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
set -- "${POSITIONAL[@]+"${POSITIONAL[@]}"}"
# ---------------------------------------------------------------------------
# device helpers
# ---------------------------------------------------------------------------
# Wait up to `timeout` seconds for a device to appear. A freshly launched
# emulator takes appreciably longer than a fixed sleep to register with adb,
# and the port varies (5554, 5556, ...) depending on what else is running.
detect_serial() {
[[ -n "$SERIAL" ]] && return 0
local timeout="${1:-90}" waited=0
while (( waited < timeout )); do
SERIAL="$("$ADB" devices | awk '$2=="device" {print $1; exit}')"
[[ -n "$SERIAL" ]] && { log "device: $SERIAL"; return 0; }
sleep 2; waited=$((waited + 2))
done
die "no device appeared within ${timeout}s. Run: tools/demo/capture.sh boot"
}
adbs() { "$ADB" -s "$SERIAL" "$@"; }
wait_for_boot() {
log "waiting for device boot"
"$ADB" -s "$SERIAL" wait-for-device
local tries=0
until [[ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
((tries++)); [[ $tries -gt 180 ]] && die "device did not finish booting"
sleep 2
done
log "device booted ($SERIAL)"
}
cmd_boot() {
if "$ADB" devices | grep -q "device$"; then
log "a device is already booted"; detect_serial; return 0
fi
log "booting AVD $AVD (headless)"
# -feature -GLDMA: with swiftshader_indirect on a headless host, the guest
# graphics stack otherwise aborts on
# "Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma"
# which kills screencap and screenrecord — i.e. exactly the two things this
# harness needs. Disabling the DMA colour-buffer path costs a little capture
# throughput and makes both work.
nohup "$EMULATOR_BIN" -avd "$AVD" -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect -feature -GLDMA -no-snapshot-save \
> "$OUT_ROOT/emulator.log" 2>&1 &
sleep 5
detect_serial
wait_for_boot
}
set_overlays() {
# Native touch indicators. Far cleaner than compositing a fake cursor: this
# is the real system-level ripple, drawn by SurfaceFlinger, so it appears in
# screenrecord exactly as a user would see it.
local on="$1"
if [[ "$on" == "taps" ]]; then
log "touch indicators ON"
adbs shell settings put system show_touches 1 || true
adbs shell settings put system pointer_location 0 || true
else
log "touch indicators OFF"
adbs shell settings put system show_touches 0 || true
adbs shell settings put system pointer_location 0 || true
fi
}
prep_device_for_camera() {
# A clean status bar. Google's own demo-mode API — no root needed.
adbs shell settings put global sysui_demo_allowed 1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command enter >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 >/dev/null 2>&1 || true
adbs shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false >/dev/null 2>&1 || true
# Kill animations-off if a previous test run set them; we WANT animations.
adbs shell settings put global window_animation_scale 1 || true
adbs shell settings put global transition_animation_scale 1 || true
adbs shell settings put global animator_duration_scale 1 || true
}
exit_demo_mode() {
adbs shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 || true
}
# ---------------------------------------------------------------------------
# record
# ---------------------------------------------------------------------------
cmd_record() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh record <scenario> (${SCENARIOS[*]})"
[[ -f "$REPO_ROOT/integration_test/demo/scenarios/$scenario.dart" ]] \
|| die "no such scenario: $scenario"
mkdir -p "$RAW_DIR"
detect_serial
wait_for_boot
set_overlays "$OVERLAYS"
prep_device_for_camera
# Pull the demo seed only if the scenario needs it.
local seed=""
if [[ "$scenario" != "onboarding_create" ]]; then
if seed="$(demo_secret seed 2>/dev/null)"; then
log "demo wallet seed loaded from Vaultwarden ($(echo "$seed" | wc -w) words)"
else
warn "no demo seed available — scenario will run against whatever wallet the device already has"
seed=""
fi
fi
local send_addr="${DEMO_SEND_ADDRESS:-}"
local dev_epoch_start
dev_epoch_start="$(adbs shell date +%s%3N | tr -d '\r')"
log "starting screenrecord"
# /data/local/tmp, not /sdcard: the emulator's emulated-storage FUSE layer
# is flaky headless ("Transport endpoint is not connected"), and screenrecord
# then fails to open its output. /data/local/tmp is a plain filesystem that
# adb can always read and write.
adbs shell rm -f "$DEV_CAPTURE" || true
adbs shell screenrecord --size 1080x1920 --bit-rate 12000000 \
--time-limit 180 "$DEV_CAPTURE" &
local rec_pid=$!
sleep 2 # let the encoder actually start before the app launches
log "driving scenario: $scenario"
set +e
docker run --rm --network host \
-v "$REPO_ROOT":/work -w /work \
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
-e DEMO_SEED="$seed" -e DEMO_SEND_ADDR="$send_addr" \
-e SCENARIO="$scenario" -e SERIAL="$SERIAL" \
"$IMAGE" bash -c '
trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT
git config --global --add safe.directory "*"
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/demo/scenarios/'"$scenario"'.dart \
-d "$SERIAL" \
--dart-define-from-file=env.json \
--dart-define=DEMO_WALLET_SEED="$DEMO_SEED" \
--dart-define=DEMO_SEND_ADDRESS="$DEMO_SEND_ADDR"
' 2>&1 | tee "$RAW_DIR/$scenario.drive.log"
local drive_rc=${PIPESTATUS[0]}
set -e
log "stopping screenrecord"
adbs shell pkill -INT screenrecord || true
wait "$rec_pid" 2>/dev/null || true
sleep 3 # screenrecord needs a moment to finalise the moov atom
adbs pull "$DEV_CAPTURE" "$RAW_DIR/$scenario.mp4" >/dev/null \
|| die "could not pull recording — did screenrecord start?"
adbs shell rm -f "$DEV_CAPTURE" || true
# Driver output: marks + blindfold spans.
if [[ -f "$REPO_ROOT/integration_test/integration_response_data.json" ]]; then
cp "$REPO_ROOT/integration_test/integration_response_data.json" \
"$RAW_DIR/$scenario.marks.json"
else
warn "no driver output — captions and screenshots will be unavailable"
fi
echo "$dev_epoch_start" > "$RAW_DIR/$scenario.recstart"
exit_demo_mode
if [[ $drive_rc -ne 0 ]]; then
warn "scenario exited non-zero ($drive_rc); the recording was still saved."
warn "check $RAW_DIR/$scenario.drive.log"
fi
log "raw recording: $RAW_DIR/$scenario.mp4"
cmd_cut "$scenario"
}
# ---------------------------------------------------------------------------
# cut — trim launch/install lead-in and excise every blindfold span
# ---------------------------------------------------------------------------
cmd_cut() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh cut <scenario>"
local raw="$RAW_DIR/$scenario.mp4"
[[ -f "$raw" ]] || die "no raw recording for $scenario"
mkdir -p "$CUT_DIR"
python3 "$HERE/marks.py" cut \
--raw "$raw" \
--marks "$RAW_DIR/$scenario.marks.json" \
--recstart "$RAW_DIR/$scenario.recstart" \
--out "$CUT_DIR/$scenario.mp4" \
--manifest "$CUT_DIR/$scenario.manifest.json"
log "cut clip: $CUT_DIR/$scenario.mp4"
}
# ---------------------------------------------------------------------------
# shots — store screenshots pulled from the recording at mark timestamps
# ---------------------------------------------------------------------------
cmd_shots() {
local scenario="${1:-}"
[[ -n "$scenario" ]] || die "usage: capture.sh shots <scenario>"
mkdir -p "$SHOT_DIR"
python3 "$HERE/marks.py" shots \
--raw "$RAW_DIR/$scenario.mp4" \
--marks "$RAW_DIR/$scenario.marks.json" \
--recstart "$RAW_DIR/$scenario.recstart" \
--outdir "$SHOT_DIR"
log "screenshots: $SHOT_DIR"
}
# ---------------------------------------------------------------------------
# reel — assemble a finished master
# ---------------------------------------------------------------------------
cmd_reel() {
local profile="${1:-social}"
mkdir -p "$PUB_DIR"
if [[ "$profile" == "apple-preview" && "$OVERLAYS" == "taps" ]]; then
die "apple-preview requires --overlays none. Re-record with:
tools/demo/capture.sh record <scenario> --overlays none
An App Preview must show the app as-is, without composited touch indicators."
fi
local manifest="$PUB_DIR/$profile.manifest.json"
local music_args=()
# apple-preview ignores music by design; reel.py enforces that too.
if [[ -n "${DEMO_MUSIC:-}" && "$profile" != "apple-preview" ]]; then
if [[ -f "$DEMO_MUSIC" ]]; then
music_args=(--music "$DEMO_MUSIC")
else
warn "DEMO_MUSIC set but not found: $DEMO_MUSIC — rendering silent"
fi
fi
python3 "$HERE/marks.py" manifest \
--cutdir "$CUT_DIR" --profile "$profile" --out "$manifest" \
--scenarios "${SCENARIOS[*]}" "${music_args[@]+"${music_args[@]}"}"
python3 "$HERE/reel.py" \
--manifest "$manifest" --profile "$profile" \
-o "$PUB_DIR/hashbags-$profile.mp4"
log "master: $PUB_DIR/hashbags-$profile.mp4"
}
cmd_all() {
cmd_boot
for s in "${SCENARIOS[@]}"; do
cmd_record "$s" || warn "scenario $s failed; continuing"
cmd_shots "$s" || warn "shots for $s failed; continuing"
done
cmd_reel social
log "done. publishable output under $PUB_DIR"
}
cmd_help() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
mkdir -p "$OUT_ROOT"
if [[ "$TARGET" == "linux" ]]; then
die "--target linux is not implemented.
The Linux desktop build DOES render the phone UI (linux/my_application.cc
forces a 720x1280 window when DESKTOP_FORCE_MOBILE is set), and Xvfb +
ffmpeg x11grab are available on this box, so it is a reasonable fast-iteration
path. What it needs first:
1. ./configure_hash_wallet.sh linux (builds the x86_64-linux-gnu monero_c libs)
2. a recorder branch here using: Xvfb :99 + DESKTOP_FORCE_MOBILE=Y
flutter drive -d linux + ffmpeg -f x11grab -video_size 720x1280 -i :99
It is deliberately NOT wired up half-way: desktop chrome, fonts and insets
differ from the phone build, so Linux footage must never be used for a store
listing. Use --target android for anything shippable."
elif [[ "$TARGET" != "android" ]]; then
die "--target must be 'android' (see help)"
fi
case "$CMD" in
boot) cmd_boot ;;
record) cmd_record "${1:-}" ;;
cut) cmd_cut "${1:-}" ;;
shots) cmd_shots "${1:-}" ;;
reel) cmd_reel "${1:-social}" ;;
all) cmd_all ;;
help|*) cmd_help ;;
esac