From 44661ce5925befa45366da05cea392d059975903 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 2 Aug 2026 15:03:04 -0400 Subject: [PATCH 1/7] fix(ios): correct Apple Team ID to D8PL9F7X33 The Appfile and Runner.xcodeproj carried 32J6BB6VUS (a stale leftover). The real Such Software LLC team is D8PL9F7X33. This did not affect CI signing: build-ios-testflight.yml rewrites DEVELOPMENT_TEAM to the APPLE_TEAM_ID secret before archiving, so TestFlight builds already signed with the correct team. This aligns local Xcode and the fastlane snapshot path (which are NOT rewritten by CI) with what CI actually uses. Team IDs are public identifiers (embedded in every distributed app's provisioning profile), not secrets. --- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- ios/fastlane/Appfile | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 From f64ee19b0c3296b42afcaba5e1979890b86b847f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 2 Aug 2026 21:29:40 -0400 Subject: [PATCH 2/7] fix(integration_test): repair compile after chain removal; robust key taps integration_test/ has not compiled since 784a082f1 removed SOL/TRX/ZEC/DCR/ ZANO/Haven: common_test_flows.dart and common_test_constants.dart still referenced WalletType.solana/.tron/.zano/.decred/.zcash/.haven and secrets that were never generated, and wallet_keys_robot branched on .solana/.tron. Any `flutter drive` over these components failed at kernel compile. - common_test_flows.dart: drop removed chains from both wallet-type switches. - common_test_constants.dart: default test wallet SOL -> WOW (Wownero-first; SOL no longer exists), with a real WOW address. - wallet_keys_robot.dart: EVM-only branch (solana/tron gone). - common_test_cases.dart tapItemByKey: wait for the widget (up to 20s) instead of pumping a flat 500ms then asserting. Screens behind an async transition (PIN-setup success button) were a coin flip; this makes the failure honest ("not there after 20s") and the pass reliable. Pump loop, not pumpAndSettle, since the dashboard sync animation never settles. --- .../components/common_test_cases.dart | 19 ++++++++++++++++--- .../components/common_test_constants.dart | 13 ++++++++----- .../components/common_test_flows.dart | 19 +++---------------- .../robots/wallet_keys_robot.dart | 6 +++--- 4 files changed, 30 insertions(+), 27 deletions(-) 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) { From 731adb84f10fa12158934fd97afd57690cc0fee9 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 2 Aug 2026 21:29:40 -0400 Subject: [PATCH 3/7] fix(demo): API35 capture recipe, reown unshallow, deferred recording, device match Hardening from the first real end-to-end runs against the app: - build-apk.sh: guard bitbox on the api.aar artifact (not the dir); unshallow the reown_flutter tarball so prepare_reown.sh can checkout its pinned commit (the tarball ships a shallow repo -> "unable to read tree"); generate reown uniffi bindings when absent. - capture.sh boot: API 35 + `-gpu software -feature -Vulkan`. API 36.1's guest encoder is broken (screenrecord err=-38, screencap DMA assert) under both swiftshader_indirect and -gpu host; API 35 works. - capture.sh: defer screenrecord until the app is foregrounded (flutter drive builds for minutes; the 180s cap must cover the scenario, not the build); match the target device by AVD name so a co-running emulator isn't hijacked; record to /data/local/tmp (headless /sdcard FUSE is flaky). - README: document the API-35 requirement and reasoning. --- tools/demo/README.md | 26 ++++++++-- tools/demo/build-apk.sh | 12 +++++ tools/demo/capture.sh | 107 ++++++++++++++++++++++++++++++---------- 3 files changed, 114 insertions(+), 31 deletions(-) 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..bb8500331 100755 --- a/tools/demo/build-apk.sh +++ b/tools/demo/build-apk.sh @@ -133,6 +133,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." diff --git a/tools/demo/capture.sh b/tools/demo/capture.sh index e18bc6d89..6d31b79b2 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 @@ -230,9 +280,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 +300,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 From 4be242448628d36510d28a01926587b40da43cbf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 3 Aug 2026 08:33:22 -0400 Subject: [PATCH 4/7] =?UTF-8?q?feat(demo):=20DEMO=5FMODE=20=E2=80=94=20boo?= =?UTF-8?q?t=20straight=20to=20an=20unlocked=20wallet=20for=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- lib/entities/demo_mode.dart | 81 +++++++++++++++++++++++++++++++++++++ lib/main.dart | 18 ++++++--- tools/demo/build-apk.sh | 12 +++++- tools/demo/capture.sh | 2 + 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 lib/entities/demo_mode.dart diff --git a/lib/entities/demo_mode.dart b/lib/entities/demo_mode.dart new file mode 100644 index 000000000..6cb59abaa --- /dev/null +++ b/lib/entities/demo_mode.dart @@ -0,0 +1,81 @@ +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: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 +const bool _demoModeDefine = bool.fromEnvironment('DEMO_MODE'); +bool get isDemoMode => kDebugMode && _demoModeDefine; + +/// 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'; + +/// 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; + } + + // 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/build-apk.sh b/tools/demo/build-apk.sh index bb8500331..e8fbe281d 100755 --- a/tools/demo/build-apk.sh +++ b/tools/demo/build-apk.sh @@ -45,6 +45,8 @@ 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 HOST_UID="$HOST_UID" \ -e HOST_GID="$HOST_GID" \ "$IMAGE" bash -c ' @@ -214,7 +216,15 @@ step "compile svg assets" 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. +DEMO_DEFINES="" +if [[ "${DEMO:-0}" == "1" ]]; then + DEMO_DEFINES="--dart-define=DEMO_MODE=true --dart-define=DEMO_PIN=${DEMO_PIN:-0801}" + echo "DEMO_MODE baked in" +fi +flutter build apk --dart-define-from-file=env.json --$BUILD_MODE $EXTRA $DEMO_DEFINES echo echo "==> artifacts:" diff --git a/tools/demo/capture.sh b/tools/demo/capture.sh index 6d31b79b2..fcc890320 100755 --- a/tools/demo/capture.sh +++ b/tools/demo/capture.sh @@ -272,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" From 4dd15704b02a52eb234cdd2864920188583f9c1f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 3 Aug 2026 08:36:30 -0400 Subject: [PATCH 5/7] feat(demo): honor SCREENSHOT_MODE as alias for the iOS UITest path The iOS fastlane snapshot UITest already passes --dart-define=SCREENSHOT_MODE= true; accept it as an alias for DEMO_MODE so the Mac screenshot path drives the same demo-wallet boot without changing the UITest. Trivial const OR; validated on next build. --- lib/entities/demo_mode.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/entities/demo_mode.dart b/lib/entities/demo_mode.dart index 6cb59abaa..bd8549593 100644 --- a/lib/entities/demo_mode.dart +++ b/lib/entities/demo_mode.dart @@ -20,8 +20,13 @@ import 'package:shared_preferences/shared_preferences.dart'; /// /// 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'); -bool get isDemoMode => kDebugMode && _demoModeDefine; +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'); From 2afcf010e1aeeb265efba62471d9411ee1ea2d44 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 3 Aug 2026 13:37:47 -0400 Subject: [PATCH 6/7] feat(demo): funded-wallet restore + build-arg fixes Adds DEMO_WALLET_SEED/DEMO_WALLET_HEIGHT support so a demo build restores a fixed (throwaway, funded) Wownero wallet instead of creating a fresh one, via WalletRestoreViewModel.create. Balance lives on-chain against the seed, so it survives any emulator wipe and is reproducible. Three real bugs fixed on the way: - build-apk.sh built the --dart-define args as a word-split string; the 16-word seed's internal spaces split into separate argv tokens and flutter treated a seed word as a build target ("Target file '' not found"). Now a bash array keeps each --dart-define one token. - WalletRestoreViewModel.getCredentials reads name/passphrase from the options MAP (not the vm field) and casts name as non-null String, so the restore options must include 'name' and 'passphrase'. - forward DEMO_WALLET_SEED/HEIGHT into the build container; source demo.env. Verified end to end: demo build restores the wallet, its address matches the funded one exactly (QR-decoded), it syncs at height 861800, and the dashboard shows the real 13.37 WOW balance + tx history. Send shows Max 13.37. The seed lives only in gitignored tools/demo/demo.env (never committed). docs/internal-notes.md (gitignored) documents the FailedDecryption gotcha. --- .gitignore | 3 +++ lib/entities/demo_mode.dart | 36 ++++++++++++++++++++++++++++++++++++ tools/demo/build-apk.sh | 29 +++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 6 deletions(-) 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/lib/entities/demo_mode.dart b/lib/entities/demo_mode.dart index bd8549593..8a21cc1ec 100644 --- a/lib/entities/demo_mode.dart +++ b/lib/entities/demo_mode.dart @@ -10,6 +10,7 @@ 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. @@ -34,6 +35,17 @@ 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). @@ -65,6 +77,30 @@ Future maybeSetupDemoWallet() async { 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 diff --git a/tools/demo/build-apk.sh b/tools/demo/build-apk.sh index e8fbe281d..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}" @@ -47,6 +51,8 @@ docker run --rm \ -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 ' @@ -214,17 +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" # 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. -DEMO_DEFINES="" +# 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}" - echo "DEMO_MODE baked in" + 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 -flutter build apk --dart-define-from-file=env.json --$BUILD_MODE $EXTRA $DEMO_DEFINES +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:" From 985cbf006416b55235af7ed21ef9b36433074389 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 3 Aug 2026 14:48:29 -0400 Subject: [PATCH 7/7] fix(ios): recover wallet password across Keychain param changes; no crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getWalletPassword force-unwrapped the secure-storage read, so a null iOS Keychain result (item written by an older build under different Keychain params, e.g. after a TestFlight update) hard-crashed the app and the wallet showed "Corrupted seeds" — reported by a tester whose older Wownero and BTC wallets broke on update. Now it: - falls back to the legacy readNoIOptions path (queries the OTHER iOS Keychain accessibility), which can AUTO-RECOVER such wallets without a seed restore; - falls back to the #___bak__# backup password the Monero migration writes before re-encrypting; - throws a catchable error instead of a null-check crash, so the loader can offer seed/restore recovery rather than the app dying. Only changes behavior on the previously-crashing null path — strictly safer. Not the file cipher (iOS is always Salsa20) nor the Monero migration (Wownero/BTC don't run it): the wallet files are intact, only the Keychain password was unreadable. flutter_secure_storage stays pinned (ca897a08) and the accessibility stays first_unlock; those must not change without a migration (see docs/internal-notes.md). --- lib/core/key_service.dart | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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 {