diff --git a/.gitignore b/.gitignore index 10fcf303b..6fa4f20f8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/integration_test/components/common_test_cases.dart b/integration_test/components/common_test_cases.dart index c8f9843a6..e17fd5eb8 100644 --- a/integration_test/components/common_test_cases.dart +++ b/integration_test/components/common_test_cases.dart @@ -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); diff --git a/integration_test/components/common_test_constants.dart b/integration_test/components/common_test_constants.dart index f26254401..441b10f21 100644 --- a/integration_test/components/common_test_constants.dart +++ b/integration_test/components/common_test_constants.dart @@ -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'; } diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart index f9727f5a9..0821e0dd2 100644 --- a/integration_test/components/common_test_flows.dart +++ b/integration_test/components/common_test_flows.dart @@ -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: diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart index 1c34e3577..ffd77c3f5 100644 --- a/integration_test/robots/wallet_keys_robot.dart +++ b/integration_test/robots/wallet_keys_robot.dart @@ -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) { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 43273e5e4..1b97ca417 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile index 7e4bdffc0..c10752322 100644 --- a/ios/fastlane/Appfile +++ b/ios/fastlane/Appfile @@ -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 diff --git a/lib/core/key_service.dart b/lib/core/key_service.dart index 2d753a112..4eb60db3d 100644 --- a/lib/core/key_service.dart +++ b/lib/core/key_service.dart @@ -10,8 +10,40 @@ class KeyService { Future 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 saveWalletPassword({required String walletName, required String password}) async { diff --git a/lib/entities/demo_mode.dart b/lib/entities/demo_mode.dart new file mode 100644 index 000000000..8a21cc1ec --- /dev/null +++ b/lib/entities/demo_mode.dart @@ -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 maybeSetupDemoWallet() async { + if (!isDemoMode) return; + + try { + final authStore = getIt.get(); + final authService = getIt.get(); + final prefs = getIt.get(); + + 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( + 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: { + '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( + param1: NewWalletArguments(type: WalletType.wownero)); + vm.name = _demoWalletName; + await vm.create(options: ['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'); + } +} diff --git a/lib/main.dart b/lib/main.dart index 383317c76..9b9c96e37 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 with SingleTickerProviderStateMixin { final settingsStore = appStore.settingsStore; final statusBarColor = Colors.transparent; final authenticationStore = getIt.get(); - 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; diff --git a/tools/demo/README.md b/tools/demo/README.md index caf78893f..59636f986 100644 --- a/tools/demo/README.md +++ b/tools/demo/README.md @@ -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. diff --git a/tools/demo/build-apk.sh b/tools/demo/build-apk.sh index 85712c5b8..9b3b017f9 100755 --- a/tools/demo/build-apk.sh +++ b/tools/demo/build-apk.sh @@ -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 `, 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:" diff --git a/tools/demo/capture.sh b/tools/demo/capture.sh index e18bc6d89..fcc890320 100755 --- a/tools/demo/capture.sh +++ b/tools/demo/capture.sh @@ -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 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