Merge pull request 'Dev' (#39) from github-such-software/hash-wallet:dev into dev

Reviewed-on: Builds/hash-wallet#39
This commit is contained in:
2026-08-03 15:46:56 -04:00
13 changed files with 352 additions and 72 deletions
+3
View File
@@ -244,3 +244,6 @@ scripts/windows/setup-windows-runner.ps1
# committed template is tools/demo/demo.env.example.
tools/demo/demo.env
__pycache__/
# Internal engineering notes (not for the public repo)
docs/internal-notes.md
@@ -14,12 +14,25 @@ class CommonTestCases {
String key, {
bool shouldPumpAndSettle = true,
int pumpDuration = 100,
Duration timeout = const Duration(seconds: 20),
}) async {
await tester.pump(Duration(milliseconds: 500));
final widgetFinder = find.byKey(ValueKey(key));
expect(tester.any(widgetFinder), true, reason: 'Widget with key "$key" should be visible');
// Wait for the widget to actually appear rather than pumping a flat 500ms
// and hoping. Screens that arrive behind an async transition (PIN setup's
// success button is the usual offender) are otherwise a coin flip, and the
// failure reads as "key missing" rather than "key not there YET".
//
// Deliberately a pump loop, not pumpAndSettle: the dashboard runs a
// perpetual sync-indicator animation, so settling never completes there.
final deadline = DateTime.now().add(timeout);
while (!tester.any(widgetFinder) && DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(tester.any(widgetFinder), true,
reason: 'Widget with key "$key" should be visible '
'(waited ${timeout.inSeconds}s)');
final widget = widgetFinder.first;
await tester.tap(widget, warnIfMissed: false);
@@ -6,10 +6,13 @@ class CommonTestConstants {
static final String sendTestAmount = '0.00008';
static final String sendTestFiatAmount = '1.00';
static final String exchangeTestAmount = '0.01';
static final WalletType testWalletType = WalletType.solana;
// Hash Bags is Wownero-first and no longer ships Solana, so the defaults
// moved off SOL. WalletType.solana does not exist in this fork.
static final WalletType testWalletType = WalletType.wownero;
static final String testWalletName = 'Integrated Testing Wallet';
static final CryptoCurrency sendTestReceiveCurrency = CryptoCurrency.sol;
static final CryptoCurrency exchangeTestReceiveCurrency = CryptoCurrency.usdtSol;
static final CryptoCurrency exchangeTestDepositCurrency = CryptoCurrency.sol;
static final String testWalletAddress = '7TSTXnQZxQDA4JoNqs4DqVqE7oNWk2kmHkMT6DD6vw2S';
static final CryptoCurrency sendTestReceiveCurrency = CryptoCurrency.wow;
static final CryptoCurrency exchangeTestReceiveCurrency = CryptoCurrency.btc;
static final CryptoCurrency exchangeTestDepositCurrency = CryptoCurrency.wow;
static final String testWalletAddress =
'Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHJLAjaZYLBZnDh42Q2u1Es9J';
}
@@ -352,30 +352,21 @@ class CommonTestFlows {
return secrets.bitcoinCashTestWalletSeeds;
case WalletType.polygon:
return secrets.polygonTestWalletSeeds;
case WalletType.solana:
return secrets.solanaTestWalletSeeds;
case WalletType.base:
return secrets.baseTestWalletSeeds;
case WalletType.arbitrum:
return secrets.arbitrumTestWalletSeeds;
case WalletType.bsc:
return secrets.bscTestWalletSeeds;
case WalletType.tron:
return secrets.tronTestWalletSeeds;
case WalletType.nano:
return secrets.nanoTestWalletSeeds;
case WalletType.wownero:
return secrets.wowneroTestWalletSeeds;
case WalletType.zano:
return secrets.zanoTestWalletSeeds;
case WalletType.decred:
return secrets.decredTestWalletSeeds;
// Hash Bags does not ship SOL/TRX/ZEC/DCR/ZANO/Haven — those WalletType
// members no longer exist. dogecoin ships, but has no test seed in
// .secrets.g.dart.
case WalletType.dogecoin:
return secrets.dogeTestWalletSeeds;
case WalletType.zcash:
return secrets.zcashTestWalletSeeds;
case WalletType.none:
case WalletType.haven:
case WalletType.banano:
throw Exception("Unable to get seeds for ${walletType}");
}
@@ -402,10 +393,6 @@ class CommonTestFlows {
return secrets.arbitrumTestWalletReceiveAddress;
case WalletType.bsc:
return secrets.bscTestWalletReceiveAddress;
case WalletType.solana:
return secrets.solanaTestWalletReceiveAddress;
case WalletType.tron:
return secrets.tronTestWalletReceiveAddress;
case WalletType.nano:
return secrets.nanoTestWalletReceiveAddress;
case WalletType.wownero:
@@ -64,9 +64,9 @@ class WalletKeysAndSeedPageRobot {
tester.printToConsole('$walletName wallet has seeds properly displayed');
}
if (isEVMCompatibleChain(walletType) ||
walletType == WalletType.solana ||
walletType == WalletType.tron) {
// Hash Bags does not ship Solana or Tron; those WalletType members were
// removed with the chains, so only the EVM chains remain here.
if (isEVMCompatibleChain(walletType)) {
if (hasSeed) {
final seedWords = appStore.wallet!.seed!.split(" ");
for (var seedWord in seedWords) {
+3 -3
View File
@@ -489,7 +489,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 32J6BB6VUS;
DEVELOPMENT_TEAM = D8PL9F7X33;
DISABLED_ARCHS = x86_64;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
@@ -640,7 +640,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 32J6BB6VUS;
DEVELOPMENT_TEAM = D8PL9F7X33;
DISABLED_ARCHS = x86_64;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
@@ -684,7 +684,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 32J6BB6VUS;
DEVELOPMENT_TEAM = D8PL9F7X33;
DISABLED_ARCHS = x86_64;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
+1 -1
View File
@@ -1,3 +1,3 @@
app_identifier("com.suchsoftware.hashwallet")
team_id("32J6BB6VUS") # Apple Developer Team (Such Software LLC)
team_id("D8PL9F7X33") # Apple Developer Team (Such Software LLC)
# apple_id("you@such.software") # fill in only if you later use fastlane to upload
+34 -2
View File
@@ -10,8 +10,40 @@ class KeyService {
Future<String> getWalletPassword({required String walletName}) async {
final key =
generateStoreKeyFor(key: SecretStoreKey.moneroWalletPassword, walletName: walletName);
final encodedPassword = await _secureStorage.read(key: key);
return decodeWalletPassword(password: encodedPassword!);
// Primary read (current iOS Keychain accessibility).
var encodedPassword = await _secureStorage.read(key: key);
// Fallback 1: the legacy read path queries the OTHER iOS Keychain
// accessibility. A wallet whose password item was written by an older
// build with different Keychain params reads back null on the primary
// path — this recovers it. That is the "corrupted seeds after a TestFlight
// update" failure (Wownero/BTC/etc.: wallet files intact, Keychain entry
// unreadable under the new params).
encodedPassword ??= await _secureStorage.readNoIOptions(key: key);
// Fallback 2: the backup password the Monero password migration saves
// under a side key BEFORE it re-encrypts, in case the main save did not
// persist (see WalletLoadingService.updateMoneroWalletPassword).
if (encodedPassword == null) {
final bakKey = generateStoreKeyFor(
key: SecretStoreKey.moneroWalletPassword,
walletName: '#__${walletName}_bak__#');
encodedPassword = await _secureStorage.read(key: bakKey) ??
await _secureStorage.readNoIOptions(key: bakKey);
}
// Fail with a clear, CATCHABLE error instead of a null-check crash, so the
// loader can surface the seed/restore recovery path rather than the app
// hard-crashing ("crashed and wouldn't let me in").
if (encodedPassword == null) {
throw Exception(
'Wallet password not found in secure storage for "$walletName". '
'The keychain entry may have been lost across an app update; '
'restore this wallet from its seed.');
}
return decodeWalletPassword(password: encodedPassword);
}
Future<void> saveWalletPassword({required String walletName, required String password}) async {
+122
View File
@@ -0,0 +1,122 @@
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:flutter/foundation.dart';
import 'package:hash_wallet/core/auth_service.dart';
import 'package:hash_wallet/core/execution_state.dart';
import 'package:hash_wallet/core/new_wallet_arguments.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/load_current_wallet.dart';
import 'package:hash_wallet/entities/preferences_key.dart';
import 'package:hash_wallet/entities/seed_type.dart';
import 'package:hash_wallet/store/authentication_store.dart';
import 'package:hash_wallet/view_model/wallet_new_vm.dart';
import 'package:hash_wallet/view_model/wallet_restore_view_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Whether this build was launched in capture/demo mode.
///
/// Compile-time const AND gated on [kDebugMode], so in a release build both
/// halves fold to false and every demo path below is tree-shaken out. It is
/// impossible to enable DEMO_MODE in a store binary.
///
/// Enable for a local capture build with:
/// flutter run --dart-define=DEMO_MODE=true
///
/// SCREENSHOT_MODE is honored as an alias so the iOS fastlane snapshot UITest
/// (ios/RunnerUITests/HashBagsScreenshotsUITests.swift, which already passes
/// --dart-define=SCREENSHOT_MODE=true) drives this same path unchanged.
const bool _demoModeDefine = bool.fromEnvironment('DEMO_MODE');
const bool _screenshotModeDefine = bool.fromEnvironment('SCREENSHOT_MODE');
bool get isDemoMode => kDebugMode && (_demoModeDefine || _screenshotModeDefine);
/// PIN the demo build "unlocks" with.
const String _demoPin = String.fromEnvironment('DEMO_PIN', defaultValue: '0801');
/// Name of the throwaway wallet the demo build creates on first launch.
const String _demoWalletName = 'Demo Wallet';
/// Optional fixed seed. When set, the demo build RESTORES this exact wallet
/// instead of creating a fresh one — so a funded throwaway wallet is
/// reproducible and its balance (which lives on-chain against the seed)
/// survives any emulator wipe. THROWAWAY only; never a real wallet. Supplied
/// via --dart-define, kept out of git (tools/demo/demo.env).
const String _demoSeed = String.fromEnvironment('DEMO_WALLET_SEED');
/// Restore height for the seed above. Set it near the wallet's creation block
/// so sync is fast; 0 scans from genesis (slow).
const int _demoHeight = int.fromEnvironment('DEMO_WALLET_HEIGHT', defaultValue: 0);
/// Bring the app up straight into a usable, unlocked wallet so the capture
/// harness never has to drive onboarding or the PIN pad (whose synthetic taps
/// are unreliable under the headless emulator).
///
/// First launch: create a fresh Wownero wallet through the real new-wallet
/// view model — so walletInfo, derivation, paths, persistence and
/// changeCurrentWallet all happen exactly as they do in the UI flow, just
/// without the screens. No seed import, no network sync: instant, deterministic,
/// zero balance. Later launches: reuse the wallet already on disk.
///
/// This is a THROWAWAY wallet. Never fund it. To shoot balances/history, point
/// a demo build at a funded wallet instead (out of scope here).
Future<void> maybeSetupDemoWallet() async {
if (!isDemoMode) return;
try {
final authStore = getIt.get<AuthenticationStore>();
final authService = getIt.get<AuthService>();
final prefs = getIt.get<SharedPreferences>();
await authService.setPassword(_demoPin);
final existing = prefs.getString(PreferencesKey.currentWalletName);
if (existing != null) {
// A demo wallet already exists from a prior run — just load it.
await loadCurrentWallet();
authStore.allowed();
printV('[demo] loaded existing wallet "$existing"');
return;
}
if (_demoSeed.trim().isNotEmpty) {
// Reproducible funded wallet: restore the fixed seed. WalletRestoreView
// Model shares WalletCreationVM.create, so it builds walletInfo/paths and
// finalizes (changeCurrentWallet + allowedCreate) just like the new-wallet
// flow.
final vm = getIt.get<WalletRestoreViewModel>(
param1: WalletType.wownero, param2: null);
vm.name = _demoWalletName;
// getCredentials() reads name/passphrase FROM the options map (not the vm
// field), and casts name as a non-null String — so it must be present.
await vm.create(options: <String, dynamic>{
'name': _demoWalletName,
'seed': _demoSeed.trim(),
'height': _demoHeight,
'passphrase': '',
});
if (vm.state is! ExecutedSuccessfullyState) {
throw Exception('wallet restore did not finish (${vm.state.runtimeType})');
}
printV('[demo] restored Wownero wallet from DEMO_WALLET_SEED '
'(height $_demoHeight)');
return;
}
// Fresh Wownero wallet via the real create flow. WalletNewVM.create builds
// the WalletInfo, derivation, and paths, creates + saves the wallet, calls
// AppStore.changeCurrentWallet (persisting currentWalletName/type) and
// AuthenticationStore.allowedCreate() on success.
final vm = getIt.get<WalletNewVM>(
param1: NewWalletArguments(type: WalletType.wownero));
vm.name = _demoWalletName;
await vm.create(options: <dynamic>['English', MoneroSeedType.polyseed]);
if (vm.state is! ExecutedSuccessfullyState) {
throw Exception('wallet create did not finish (${vm.state.runtimeType})');
}
printV('[demo] created fresh Wownero wallet "$_demoWalletName"');
} catch (e, s) {
// Never let a demo-only path crash the app; fall back to normal onboarding
// so the failure is visible rather than fatal.
printV('[demo] setup failed, falling back to onboarding: $e\n$s');
}
}
+13 -5
View File
@@ -13,6 +13,7 @@ import 'package:hash_wallet/core/trade_monitor.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/contact.dart';
import 'package:hash_wallet/entities/default_settings_migration.dart';
import 'package:hash_wallet/entities/demo_mode.dart';
import 'package:hash_wallet/entities/get_encryption_key.dart';
import 'package:hash_wallet/entities/language_service.dart';
import 'package:hash_wallet/entities/template.dart';
@@ -162,6 +163,11 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
ProxyWrapper.logger = MemoryProxyLogger();
}
// Capture/demo builds only (kDebugMode + --dart-define=DEMO_MODE): come up
// straight into an unlocked wallet so screenshots/video never depend on the
// onboarding/PIN flow. No-op in release.
await maybeSetupDemoWallet();
// Basically when we're running a test
if (topLevelKey != null) {
runApp(
@@ -376,11 +382,13 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
final settingsStore = appStore.settingsStore;
final statusBarColor = Colors.transparent;
final authenticationStore = getIt.get<AuthenticationStore>();
final initialRoute = authenticationStore.state == AuthenticationState.uninitialized
? Routes.welcome
: settingsStore.currentBuiltinTor
? Routes.startTor
: Routes.login;
final initialRoute = isDemoMode
? Routes.dashboard
: authenticationStore.state == AuthenticationState.uninitialized
? Routes.welcome
: settingsStore.currentBuiltinTor
? Routes.startTor
: Routes.login;
final currentTheme = appStore.themeStore.currentTheme;
final statusBarBrightness =
currentTheme.type == currentTheme.isDark ? Brightness.light : Brightness.dark;
+21 -5
View File
@@ -94,12 +94,28 @@ no captions, no title/CTA cards, no music bed. `reel.py` also enforces
Apple's 15–30s duration window and will fail rather than emit an out-of-spec
preview.
## Store sizing
## The capture AVD (both details matter)
The harness creates its own AVD at exactly **1080×1920 (16:9)**. Do not point
it at the stock "Medium Phone" AVD: that is 1080×2400 = **2.22:1**, and Google
Play rejects phone screenshots above **2:1**. `marks.py shots` warns if the
frames it extracts violate that ratio.
Create it as **API 35**, at exactly **1080×1920**:
```bash
avdmanager create avd -n HashBags_Capture \
-k "system-images;android-35;google_apis;x86_64" -d pixel_6
# then in ~/.android/avd/HashBags_Capture.avd/config.ini:
# hw.lcd.width=1080 hw.lcd.height=1920 hw.lcd.density=420
# hw.ramSize=6144 showDeviceFrame=no
```
**Size:** the stock "Medium Phone" AVD is 1080×2400 = **2.22:1**, and Play
rejects phone screenshots above **2:1**. `marks.py shots` warns if extracted
frames violate that ratio.
**API level:** use 35, not 36. On the API 36.1 image the guest MediaCodec AVC
encoder is broken — `screenrecord` fails with `Encoder failed (err=-38)` at
every resolution and writes a 0-byte file, and `screencap` aborts on a
SwiftShader DMA assertion. Verified broken under `swiftshader_indirect` *and*
under `-gpu host` on a real NVIDIA GPU. API 35 + `-gpu software`
+ `-feature -Vulkan` (what `capture.sh boot` uses) works.
iOS App Store screenshots and the App Preview still have to come from a Mac —
see `store/SCREENSHOTS.md` for the fastlane snapshot path.
+42 -3
View File
@@ -19,6 +19,10 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Pull in local capture config (DEMO_WALLET_SEED/HEIGHT/PIN). Gitignored.
_DEMO_ENV="$(dirname "${BASH_SOURCE[0]}")/demo.env"
if [[ -f "$_DEMO_ENV" ]]; then set -a; . "$_DEMO_ENV"; set +a; fi
IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}"
BUILD_MODE="${BUILD_MODE:-debug}"
ABI="${ABI:-x86_64}"
@@ -45,6 +49,10 @@ docker run --rm \
-e SKIP_DEPS="$SKIP_DEPS" \
-e BUILD_MODE="$BUILD_MODE" \
-e TARGET_PLATFORM="$TARGET_PLATFORM" \
-e DEMO="${DEMO:-0}" \
-e DEMO_PIN="${DEMO_PIN:-0801}" \
-e DEMO_WALLET_SEED="${DEMO_WALLET_SEED:-}" \
-e DEMO_WALLET_HEIGHT="${DEMO_WALLET_HEIGHT:-0}" \
-e HOST_UID="$HOST_UID" \
-e HOST_GID="$HOST_GID" \
"$IMAGE" bash -c '
@@ -133,6 +141,18 @@ if [[ "$SKIP_DEPS" != "1" ]]; then
elif [[ -x scripts/android/build_reown_deps.sh ]]; then
echo "uniffi bindings missing -- generating via build_reown_deps.sh"
echo "(this needs the Rust/uniffi toolchain and is slow on a cold run)"
# The prebuilt tarball unpacks a SHALLOW git repo. prepare_reown.sh then
# does `git fetch -a && git checkout <pinned sha>`, which cannot reach that
# commit through a shallow history and dies with
# fatal: unable to read tree (8a6d79ef...)
# Unshallow in place rather than re-cloning: the tarball also carries
# prebuilt native artifacts that a bare clone would not have.
if [[ -d scripts/reown_flutter/.git ]] && \
[[ "$(git -C scripts/reown_flutter rev-parse --is-shallow-repository)" == "true" ]]; then
echo "unshallowing scripts/reown_flutter so the pinned commit is reachable"
git -C scripts/reown_flutter fetch --unshallow --tags || \
git -C scripts/reown_flutter fetch --depth=2147483647 --tags || true
fi
./scripts/android/build_reown_deps.sh || {
echo
echo "FATAL: could not generate the reown uniffi bindings."
@@ -200,9 +220,28 @@ step "compile svg assets"
./compile_graphics.sh
step "build apk ($BUILD_MODE / ${TARGET_PLATFORM:-universal})"
EXTRA=""
[[ -n "$TARGET_PLATFORM" ]] && EXTRA="--target-platform=$TARGET_PLATFORM"
flutter build apk --dart-define-from-file=env.json --$BUILD_MODE $EXTRA
# DEMO=1 bakes in DEMO_MODE so the app boots straight to an unlocked demo
# wallet (kDebugMode-gated, impossible in release). Lets capture skip the
# onboarding/PIN flow entirely.
# Use an ARRAY, not a string: the seed contains spaces (16 words), so a
# word-split string turns "--dart-define=DEMO_WALLET_SEED=word1 word2 ..."
# into separate argv tokens and flutter treats "unlock" as a target file
# ("Target file 'unlock' not found."). Array elements preserve the spaces.
DEMO_DEFINES=()
if [[ "${DEMO:-0}" == "1" ]]; then
DEMO_DEFINES+=(--dart-define=DEMO_MODE=true "--dart-define=DEMO_PIN=${DEMO_PIN:-0801}")
if [[ -n "${DEMO_WALLET_SEED:-}" ]]; then
DEMO_DEFINES+=("--dart-define=DEMO_WALLET_SEED=${DEMO_WALLET_SEED}")
DEMO_DEFINES+=("--dart-define=DEMO_WALLET_HEIGHT=${DEMO_WALLET_HEIGHT:-0}")
echo "DEMO_MODE baked in (restore from seed, height ${DEMO_WALLET_HEIGHT:-0})"
else
echo "DEMO_MODE baked in (fresh wallet)"
fi
fi
EXTRA_ARGS=()
[[ -n "$TARGET_PLATFORM" ]] && EXTRA_ARGS+=("--target-platform=$TARGET_PLATFORM")
flutter build apk --dart-define-from-file=env.json --$BUILD_MODE \
"${EXTRA_ARGS[@]}" "${DEMO_DEFINES[@]+"${DEMO_DEFINES[@]}"}"
echo
echo "==> artifacts:"
+83 -26
View File
@@ -61,6 +61,9 @@ 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; }
@@ -85,18 +88,40 @@ 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.
# 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
local timeout="${1:-90}" waited=0 cand name
while (( waited < timeout )); do
SERIAL="$("$ADB" devices | awk '$2=="device" {print $1; exit}')"
[[ -n "$SERIAL" ]] && { log "device: $SERIAL"; return 0; }
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 appeared within ${timeout}s. Run: tools/demo/capture.sh boot"
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" "$@"; }
@@ -117,14 +142,21 @@ cmd_boot() {
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.
# 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 swiftshader_indirect -feature -GLDMA -no-snapshot-save \
-gpu software -feature -Vulkan -no-snapshot -no-snapshot-save \
> "$OUT_ROOT/emulator.log" 2>&1 &
sleep 5
detect_serial
@@ -193,19 +225,37 @@ cmd_record() {
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.
# 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
adbs shell screenrecord --size 1080x1920 --bit-rate 12000000 \
--time-limit 180 "$DEV_CAPTURE" &
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=$!
sleep 2 # let the encoder actually start before the app launches
log "recorder armed — will start when $APP_PKG reaches the foreground"
log "driving scenario: $scenario"
set +e
@@ -222,6 +272,8 @@ cmd_record() {
--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"
@@ -230,9 +282,15 @@ cmd_record() {
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
@@ -244,7 +302,6 @@ cmd_record() {
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