Files
jwinterm 4be2424486 feat(demo): DEMO_MODE — boot straight to an unlocked wallet for capture
Adds a debug-only capture mode so screenshots/video never depend on the
onboarding/PIN flow, whose synthetic taps are unreliable under the headless
emulator (both flutter_test and adb input drop taps on the PIN confirm screen).

lib/entities/demo_mode.dart: on launch, when kDebugMode AND
--dart-define=DEMO_MODE=true, create a fresh throwaway Wownero wallet through
the real WalletNewVM.create() (so walletInfo/derivation/paths/persistence and
changeCurrentWallet all happen exactly as in the UI), then set the PIN and
allow auth — landing on the dashboard. Reuses the wallet on later launches.
Both halves are compile-time const/kDebugMode, so this is tree-shaken out of
release: DEMO_MODE cannot be enabled in a store binary.

lib/main.dart: call maybeSetupDemoWallet() during bootstrap and force the
initial route to the dashboard in demo mode.

tools/demo/{build-apk,capture}.sh: DEMO=1 bakes the define into a local build;
flutter drive passes it so scenarios start at the dashboard.

Verified end to end: demo build boots to a live Wownero wallet and captured a
clean 1080x1920 store set (dashboard/receive/send/swap/settings) with no PIN.
This also implements the SCREENSHOT_MODE the iOS UITest scaffold already
references (store/SCREENSHOTS.md Path B).

Note: the API-35 emulator needs -gpu host (real GPU) for deep-page rendering;
-gpu software hard-crashes qemu on the Receive page. Stills use screencap
(screenrecord's encoder is unrelated).
2026-08-03 08:33:22 -04:00

423 lines
17 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
# Package under capture — used to detect when the app is actually on screen.
APP_PKG="${DEMO_APP_PKG:-com.suchsoftware.hashwallet}"
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 OUR device to appear.
#
# Deliberately matches on AVD name rather than taking the first device adb
# lists: this box routinely has other capture emulators running (VeganIQ etc),
# and grabbing the wrong one silently records somebody else's app. Falls back
# to the sole device only when exactly one is attached.
detect_serial() {
[[ -n "$SERIAL" ]] && return 0
local timeout="${1:-90}" waited=0 cand name
while (( waited < timeout )); do
while read -r cand; do
[[ -z "$cand" ]] && continue
name="$("$ADB" -s "$cand" emu avd name 2>/dev/null | head -1 | tr -d '\r')"
if [[ "$name" == "$AVD" ]]; then
SERIAL="$cand"; log "device: $SERIAL (avd $AVD)"; return 0
fi
done < <("$ADB" devices | awk '$2=="device" {print $1}')
# Exactly one device and it is not ours by name (physical phone, say) —
# take it, since there is no ambiguity to get wrong.
local all count
all="$("$ADB" devices | awk '$2=="device" {print $1}')"
count="$(printf '%s\n' "$all" | grep -c . || true)"
if [[ "$count" == "1" ]]; then
SERIAL="$(printf '%s\n' "$all" | head -1)"
log "device: $SERIAL (only device attached)"; return 0
fi
sleep 2; waited=$((waited + 2))
done
die "no device matching AVD '$AVD' appeared within ${timeout}s.
Attached devices:
$("$ADB" devices | tail -n +2)
Run 'tools/demo/capture.sh boot', or pass --serial <name> explicitly."
}
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)"
# These exact flags matter — headless capture is fussy and most combinations
# silently produce nothing:
#
# -gpu software swiftshader_indirect aborts the guest graphics stack
# with "Assertion failed: !rcEnc->featureInfo()
# ->hasReadColorBufferDma", killing screencap.
# -feature -Vulkan without it the guest MediaCodec AVC encoder fails with
# "Encoder failed (err=-38)" at every resolution, so
# screenrecord writes a 0-byte file.
#
# The AVD must also be an **API 35** image. On API 36.1 the encoder is broken
# regardless of GPU flags — verified against both swiftshader_indirect and
# -gpu host on a real NVIDIA GPU. Same recipe as VeganIQ_API35_Capture.
nohup "$EMULATOR_BIN" -avd "$AVD" -no-window -no-audio -no-boot-anim \
-gpu software -feature -Vulkan -no-snapshot -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:-}"
# Arm the recorder to fire the moment the app hits the foreground, NOT now.
#
# `flutter drive` compiles an instrumented APK before it installs anything,
# which can take many minutes. screenrecord caps at 180s, so starting it here
# would burn the entire budget filming the launcher and stop before the app
# ever appeared. Poll for the package becoming the resumed activity, then
# start recording and stamp the device clock at that instant.
#
# /data/local/tmp, not /sdcard: the emulator's emulated-storage FUSE layer is
# flaky headless ("Transport endpoint is not connected") and screenrecord
# then cannot open its output.
adbs shell rm -f "$DEV_CAPTURE" || true
local stamp_file="$RAW_DIR/$scenario.recstart"
rm -f "$stamp_file"
(
# Wait for the app to be foregrounded (cap the wait so a failed build does
# not leave this poller alive forever).
for _ in $(seq 1 900); do
if "$ADB" -s "$SERIAL" shell dumpsys activity activities 2>/dev/null \
| grep -q "topResumedActivity.*$APP_PKG"; then
"$ADB" -s "$SERIAL" shell date +%s%3N | tr -d '\r' > "$stamp_file"
"$ADB" -s "$SERIAL" shell screenrecord --size 1080x1920 \
--bit-rate 12000000 --time-limit 180 "$DEV_CAPTURE"
exit 0
fi
sleep 1
done
) &
local rec_pid=$!
log "recorder armed — will start when $APP_PKG reaches the foreground"
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_MODE=true \
--dart-define=DEMO_PIN='"${DEMO_PIN:-0801}"' \
--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
kill "$rec_pid" 2>/dev/null || true # in case it never armed
wait "$rec_pid" 2>/dev/null || true
sleep 3 # screenrecord needs a moment to finalise the moov atom
if [[ ! -s "$stamp_file" ]]; then
warn "the recorder never armed — $APP_PKG never reached the foreground."
warn "That usually means the build or install failed; see $RAW_DIR/$scenario.drive.log"
fi
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
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