mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-11-10 08:33:54 +01:00
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.
366 lines
14 KiB
Bash
Executable File
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
|