diff --git a/.gitignore b/.gitignore index 6c8b67715..10fcf303b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,11 @@ cw_monero/cw_monero/android/.cxx/ android/key.properties android/app/key.jks +# The Android CI workflow decodes ANDROID_KEYSTORE_BASE64 to this exact path. +# That is the Play upload signing key — it must never be committable. +android/app/upload-keystore.jks +android/app/*.jks +android/app/*.keystore **/tool/.secrets-prod.json **/tool/.secrets-test.json @@ -234,3 +239,8 @@ scripts/reown_flutter docs/links.md docs/UPSTREAM_SYNC.md scripts/windows/setup-windows-runner.ps1 + +# Capture harness: local config (Vaultwarden host, demo addresses). The +# committed template is tools/demo/demo.env.example. +tools/demo/demo.env +__pycache__/ diff --git a/integration_test/demo/demo_config.dart b/integration_test/demo/demo_config.dart new file mode 100644 index 000000000..dfb54e576 --- /dev/null +++ b/integration_test/demo/demo_config.dart @@ -0,0 +1,60 @@ +import 'package:cw_core/wallet_type.dart'; + +/// Capture-time configuration, injected by `tools/demo/capture.sh` via +/// `--dart-define`. Nothing here is baked into a shipped build. +/// +/// The demo wallet seed is pulled from the self-hosted Vaultwarden at capture +/// time and passed straight through — it is never written to the repo, never +/// committed, and never placed in `.secrets.g.dart`. +class DemoConfig { + /// BIP39 / Wownero seed for the demo wallet. + static const String walletSeed = String.fromEnvironment('DEMO_WALLET_SEED'); + + /// Which wallet the demo restores into. Hash Bags is Wownero-first, so that + /// is the default face of the app in store material. + static const String _walletTypeName = + String.fromEnvironment('DEMO_WALLET_TYPE', defaultValue: 'wownero'); + + /// A receive address used to populate the Send screen. This is a + /// *destination shown on camera*, so it should be an address you are happy + /// to publish — the capture script defaults it to the demo wallet's own + /// address rather than a stranger's. + static const String sendToAddress = + String.fromEnvironment('DEMO_SEND_ADDRESS'); + + /// Amount typed into the Send screen. Never broadcast in the core scenario. + static const String sendAmount = + String.fromEnvironment('DEMO_SEND_AMOUNT', defaultValue: '0.5'); + + /// Amount typed into the Swap screen. + static const String swapAmount = + String.fromEnvironment('DEMO_SWAP_AMOUNT', defaultValue: '10'); + + /// PIN used for the demo wallet. + static List get pin { + const raw = String.fromEnvironment('DEMO_PIN', defaultValue: '0801'); + return raw.split('').map(int.parse).toList(); + } + + static bool get hasSeed => walletSeed.trim().isNotEmpty; + + static WalletType get walletType { + switch (_walletTypeName.toLowerCase()) { + case 'monero': + return WalletType.monero; + case 'bitcoin': + return WalletType.bitcoin; + case 'litecoin': + return WalletType.litecoin; + case 'wownero': + default: + return WalletType.wownero; + } + } + + /// Human label for captions/log lines. + static String get walletLabel { + final n = _walletTypeName.toLowerCase(); + return n.isEmpty ? 'Wownero' : '${n[0].toUpperCase()}${n.substring(1)}'; + } +} diff --git a/integration_test/demo/demo_session.dart b/integration_test/demo/demo_session.dart new file mode 100644 index 000000000..6ec0d6a98 --- /dev/null +++ b/integration_test/demo/demo_session.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Shared plumbing for the marketing / store-capture scenarios. +/// +/// These scenarios are NOT correctness tests. They exist to drive the real app +/// through real flows at a pace a human can watch, while the host records the +/// screen. Three things separate them from the suites in `test_suites/`: +/// +/// 1. **Pacing.** A passing test rips through screens in milliseconds. That +/// footage is unwatchable. [beat] holds on a screen long enough to read it. +/// +/// 2. **Marks.** Each beat is timestamped relative to session start and +/// reported through `binding.reportData`, which the driver writes to +/// `integration_test/integration_response_data.json`. `capture.sh` reads +/// that file to build the reel manifest, so captions land on the exact +/// frame the action happens instead of being hand-timed. +/// +/// 3. **Seed safety.** The demo wallet is a REAL wallet. [guardNoSeedOnScreen] +/// fails the run if a mnemonic is visible while recording, so a seed can +/// never end up in a published video. Scenarios that must pass through a +/// seed screen wrap it in [blindfold]. +class DemoSession { + DemoSession(this.tester, this.binding, this.scenario); + + final WidgetTester tester; + final IntegrationTestWidgetsFlutterBinding binding; + final String scenario; + + final Stopwatch _clock = Stopwatch(); + final List> _marks = >[]; + + /// Device-clock epoch (ms) at the moment the scenario started. + /// + /// The host starts `adb screenrecord` before the app is even installed, so + /// mark timestamps (relative to scenario start) mean nothing to the video + /// on their own. capture.sh reads the device clock at record start, and the + /// difference between that and this value is the offset from the first frame + /// of video to mark 0. Both numbers come from the DEVICE clock, so host/guest + /// clock skew cannot corrupt the alignment. + int startedAtEpochMs = 0; + + /// Words that, seen together in bulk, mean a mnemonic is on screen. + static const int _mnemonicWordThreshold = 12; + + /// Extra secrets the host injects (the demo wallet seed) that must never be + /// rendered while the camera rolls. Populated from --dart-define. + static const String _demoSeed = String.fromEnvironment('DEMO_WALLET_SEED'); + + double get _elapsed => _clock.elapsedMilliseconds / 1000.0; + + void begin() { + startedAtEpochMs = DateTime.now().millisecondsSinceEpoch; + _clock.start(); + _marks.add({ + 'at': 0.0, + 'label': 'start', + 'caption': null, + }); + } + + /// Record a beat. [caption], when given, becomes a burned caption in the + /// social reel at exactly this timestamp. + void mark(String label, {String? caption}) { + _marks.add({ + 'at': double.parse(_elapsed.toStringAsFixed(3)), + 'label': label, + 'caption': caption, + }); + tester.printToConsole('[demo:$scenario] ${_elapsed.toStringAsFixed(2)}s $label'); + } + + /// Hold on the current screen for [seconds] of WALL CLOCK time, pumping + /// frames throughout. + /// + /// Deliberately not `pumpAndSettle`: the dashboard runs a perpetual sync + /// indicator animation, so `pumpAndSettle` would spin until it times out. + /// Pumping on a wall-clock deadline is both safe and what makes the footage + /// watchable. + Future beat([double seconds = 1.6]) async { + final deadline = DateTime.now().add( + Duration(milliseconds: (seconds * 1000).round()), + ); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 33)); // ~30fps + } + } + + /// Beat, then record a mark — the common "let the viewer read this" move. + Future hold(String label, {String? caption, double seconds = 1.8}) async { + mark(label, caption: caption); + await beat(seconds); + } + + /// Tap a finder with camera-friendly lead-in and follow-through, so the + /// viewer sees WHAT is about to be tapped before the screen changes. + Future tap( + Finder finder, { + required String label, + String? caption, + double before = 0.9, + double after = 1.4, + }) async { + await beat(before); + mark(label, caption: caption); + await tester.tap(finder, warnIfMissed: false); + await beat(after); + } + + /// Type text at human speed instead of instantly, which reads as a paste and + /// looks fake on camera. `perChar` is the inter-keystroke delay. + Future typeSlowly( + Finder field, + String text, { + double perChar = 0.06, + }) async { + await tester.tap(field, warnIfMissed: false); + await beat(0.4); + final buffer = StringBuffer(); + for (final rune in text.runes) { + buffer.writeCharCode(rune); + await tester.enterText(field, buffer.toString()); + await tester.pump(Duration(milliseconds: (perChar * 1000).round())); + } + await beat(0.5); + } + + /// Every rendered string currently on screen. + Iterable _visibleText() sync* { + for (final element in find.byType(Text).evaluate()) { + final widget = element.widget as Text; + final data = widget.data; + if (data != null && data.isNotEmpty) yield data; + } + for (final element in find.byType(EditableText).evaluate()) { + final widget = element.widget as EditableText; + final data = widget.controller.text; + if (data.isNotEmpty) yield data; + } + } + + /// Fail loudly if a mnemonic is on screen while recording. + /// + /// Two independent checks, because either alone has a blind spot: the known + /// demo seed may be split across widgets, and an unknown seed (a freshly + /// generated one) will not match any known string. + void guardNoSeedOnScreen({String? because}) { + final visible = _visibleText().toList(); + + if (_demoSeed.isNotEmpty) { + final needle = _demoSeed.trim().toLowerCase(); + final haystack = visible.join(' ').toLowerCase(); + if (haystack.contains(needle)) { + fail('SEED ON CAMERA (demo wallet seed rendered) ' + '${because ?? ''} — scenario "$scenario". Recording aborted.'); + } + // Also catch the seed rendered one-word-per-widget. + final words = needle.split(RegExp(r'\s+')).toSet(); + final onScreenWords = haystack.split(RegExp(r'[^a-z]+')).toSet(); + final overlap = words.intersection(onScreenWords).length; + if (words.length >= _mnemonicWordThreshold && + overlap >= _mnemonicWordThreshold) { + fail('SEED ON CAMERA (demo seed words rendered individually, ' + '$overlap matches) ${because ?? ''} — scenario "$scenario".'); + } + } + + // Generic shape check: any single widget holding many lowercase words is + // almost certainly a mnemonic in this app. + for (final text in visible) { + final words = text + .trim() + .split(RegExp(r'\s+')) + .where((w) => RegExp(r'^[a-zA-Z]{3,}$').hasMatch(w)) + .toList(); + if (words.length >= _mnemonicWordThreshold) { + fail('SEED ON CAMERA (widget holds ${words.length} word-like tokens) ' + '${because ?? ''} — scenario "$scenario". Recording aborted.'); + } + } + } + + /// Run [body] with the recording explicitly marked as unsafe-to-publish. + /// + /// Used for the unavoidable seed screens in the create/restore scenarios. + /// The frames still get recorded — we cannot stop the host recorder from + /// here — so this brackets them with marks. `capture.sh` reads the resulting + /// `blindfold` spans and passes them to reel.py as regions to cut, and + /// refuses to emit a publishable master if any span is unhandled. + Future blindfold(String reason, Future Function() body) async { + mark('blindfold_start', caption: null); + tester.printToConsole( + '[demo:$scenario] BLINDFOLD START ($reason) — frames from here are ' + 'NOT publishable until cut', + ); + try { + await body(); + } finally { + mark('blindfold_end', caption: null); + tester.printToConsole('[demo:$scenario] BLINDFOLD END'); + } + } + + /// Publish the marks back to the host through the driver. + void finish() { + _clock.stop(); + mark('end'); + final existing = binding.reportData ?? {}; + existing['demo'] = { + 'scenario': scenario, + 'started_at_epoch_ms': startedAtEpochMs, + 'duration': double.parse(_elapsed.toStringAsFixed(3)), + 'marks': _marks, + 'blindfold_spans': _blindfoldSpans(), + }; + binding.reportData = existing; + } + + /// Pair up blindfold_start/blindfold_end marks into [start, end] windows. + List> _blindfoldSpans() { + final spans = >[]; + double? open; + for (final m in _marks) { + if (m['label'] == 'blindfold_start') { + open = m['at'] as double; + } else if (m['label'] == 'blindfold_end' && open != null) { + spans.add({'start': open, 'end': m['at'] as double}); + open = null; + } + } + // An unclosed span means the scenario crashed mid-seed. Treat the whole + // remainder as unsafe rather than assuming it ended. + if (open != null) { + spans.add({'start': open, 'end': _elapsed}); + } + return spans; + } +} diff --git a/integration_test/demo/scenarios/core_store_set.dart b/integration_test/demo/scenarios/core_store_set.dart new file mode 100644 index 000000000..c8d560a9e --- /dev/null +++ b/integration_test/demo/scenarios/core_store_set.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_cases.dart'; +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../../robots/send_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// CORE STORE SET — the screens that become App Store / Play screenshots and +/// the backbone of the marketing reel. +/// +/// Dashboard → Receive → Send (composed, never broadcast) → Wallets → Security. +/// +/// If the device already holds the demo wallet, this shoots it directly. If it +/// does not, the scenario restores from seed first — slower, and the restore +/// frames get excised as a blindfold span, but the footage is still correct. +/// +/// NOTHING here broadcasts a transaction. The Send screen is filled in and +/// held on the review step so the UI is fully populated, then backed out of. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: core store set', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'core_store_set'); + final flows = CommonTestFlows(tester); + final cases = CommonTestCases(tester); + final dashboard = DashboardPageRobot(tester); + final send = SendPageRobot(tester: tester); + + demo.begin(); + await flows.startAppFlow(const ValueKey('demo_core_store_set')); + await demo.beat(3.0); + + // If the device was not pre-seeded with the demo wallet, restore it now. + // The seed screens are bracketed so the frames can be cut. + if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) { + await demo.blindfold('restoring demo wallet from seed', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + await demo.beat(2.0); + } + + // ---------------- 1. Dashboard ------------------------------------ + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'on dashboard'); + await demo.hold( + 'dashboard', + caption: 'Your keys. Your coins.', + seconds: 3.5, + ); + + // ---------------- 2. Receive -------------------------------------- + await dashboard.navigateToReceivePage(); + await demo.beat(2.0); + demo.guardNoSeedOnScreen(because: 'on receive page'); + await demo.hold( + 'receive', + caption: 'Receive in a tap', + seconds: 4.0, + ); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 3. Send (compose only) -------------------------- + await dashboard.navigateToSendPage(); + await send.checkIfSendPageIsVisible(); + await demo.beat(1.2); + demo.mark('send_open', caption: 'Send with confidence'); + + if (DemoConfig.sendToAddress.isNotEmpty) { + // Typed at human speed — an instant fill reads as a paste on camera. + final addressField = find.byType(EditableText).first; + await demo.typeSlowly(addressField, DemoConfig.sendToAddress); + demo.mark('send_address_entered'); + } + await demo.beat(1.0); + await send.enterSendAmount(DemoConfig.sendAmount); + await demo.hold('send_amount_entered', seconds: 2.5); + + // Deliberately NOT calling onSendButtonPressed(). We show a fully + // composed transaction and walk away — no funds move to make a video. + demo.mark('send_composed_not_broadcast'); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 4. Wallet list ---------------------------------- + await dashboard.navigateToWalletsListPage(); + await demo.beat(2.0); + demo.guardNoSeedOnScreen(because: 'on wallet list'); + await demo.hold( + 'wallets', + caption: 'Wownero, Monero, Bitcoin & more', + seconds: 3.5, + ); + await cases.goBack(); + await demo.beat(1.5); + + // ---------------- 5. Security & backup ---------------------------- + await dashboard.openDrawerMenu(); + await demo.beat(2.0); + await demo.hold( + 'menu', + caption: 'Everything under your control', + seconds: 3.0, + ); + demo.guardNoSeedOnScreen(because: 'in drawer menu'); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/onboarding_create.dart b/integration_test/demo/scenarios/onboarding_create.dart new file mode 100644 index 000000000..a65236633 --- /dev/null +++ b/integration_test/demo/scenarios/onboarding_create.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// ONBOARDING — first launch through to a live wallet. +/// +/// The strongest marketing story the app has: no email, no KYC, no account, +/// wallet in under a minute. +/// +/// SEED SAFETY: this flow generates a brand-new wallet, so a real mnemonic +/// WILL be rendered on the seed + verification screens. Those beats run inside +/// `blindfold`, which records the span into the driver output. `capture.sh` +/// refuses to promote a master to the publishable directory while any +/// blindfold span is uncut, so the frames cannot reach a store listing by +/// accident. +/// +/// The wallet created here is throwaway. Never fund it. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: onboarding + create wallet', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'onboarding_create'); + final flows = CommonTestFlows(tester); + final dashboard = DashboardPageRobot(tester); + + demo.begin(); + + await flows.startAppFlow(const ValueKey('demo_onboarding_create')); + await demo.beat(3.0); + await demo.hold( + 'welcome', + caption: 'No email. No KYC. No account.', + seconds: 3.5, + ); + + // The create flow walks PIN setup, seed display and seed verification in + // one call. Everything from here until the dashboard is unsafe to publish. + await demo.blindfold('new wallet seed is displayed and verified', () async { + await flows.welcomePageToCreateNewWalletFlow( + DemoConfig.walletType, + DemoConfig.pin, + ); + }); + + await demo.beat(2.5); + + // Back on safe ground — verify it and shoot the payoff. + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'post-create dashboard'); + await demo.hold( + 'wallet_ready', + caption: 'A ${DemoConfig.walletLabel} wallet in under a minute', + seconds: 4.0, + ); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/restore_from_seed.dart b/integration_test/demo/scenarios/restore_from_seed.dart new file mode 100644 index 000000000..70cb946bb --- /dev/null +++ b/integration_test/demo/scenarios/restore_from_seed.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// RESTORE — bring an existing wallet back from its seed. +/// +/// The "you are not locked in" story: the seed is yours, the wallet is +/// portable, Hash Bags is just a viewer for keys you already hold. +/// +/// SEED SAFETY: the entry screen necessarily shows the demo wallet's real +/// mnemonic, so the whole restore is wrapped in `blindfold`. The publishable +/// cut starts at the dashboard. Do not lift that bracket to "get a better +/// shot" — the demo wallet holds real funds. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: restore from seed', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'restore_from_seed'); + final flows = CommonTestFlows(tester); + final dashboard = DashboardPageRobot(tester); + + if (!DemoConfig.hasSeed) { + fail('restore_from_seed needs DEMO_WALLET_SEED. Run it through ' + 'tools/demo/capture.sh, which pulls the seed from Vaultwarden.'); + } + + demo.begin(); + + await flows.startAppFlow(const ValueKey('demo_restore_from_seed')); + await demo.beat(3.0); + await demo.hold( + 'welcome', + caption: 'Already have a wallet?', + seconds: 3.0, + ); + + await demo.blindfold('demo wallet mnemonic is typed on screen', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + + await demo.beat(3.0); + + await dashboard.isDashboardPage(); + demo.guardNoSeedOnScreen(because: 'post-restore dashboard'); + await demo.hold( + 'restored', + caption: 'Your seed. Your wallet. Anywhere.', + seconds: 4.0, + ); + + // Let the sync indicator run a little — real syncing is part of the story + // and it looks alive on camera. + await demo.hold('syncing', caption: 'Syncing from the network', seconds: 4.0); + + demo.finish(); + }); +} diff --git a/integration_test/demo/scenarios/swap_and_buy.dart b/integration_test/demo/scenarios/swap_and_buy.dart new file mode 100644 index 000000000..8e44cd8d3 --- /dev/null +++ b/integration_test/demo/scenarios/swap_and_buy.dart @@ -0,0 +1,114 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../components/common_test_cases.dart'; +import '../../components/common_test_flows.dart'; +import '../../robots/dashboard_page_robot.dart'; +import '../../robots/exchange_page_robot.dart'; +import '../demo_config.dart'; +import '../demo_session.dart'; + +/// SWAP + BUY — the exchange surface. +/// +/// Shows a Trocador swap being configured and quoted. It deliberately stops +/// BEFORE `onExchangeButtonPressed()`: creating a trade moves real funds and +/// burns a real Trocador order, neither of which should happen to make a +/// marketing clip. +/// +/// BUY is gated behind provider keys (see the MoonPay key-gating commit). A +/// capture build has empty secrets, so the Buy action is hidden and this +/// scenario logs that it skipped rather than failing — which is also the +/// correct store-review posture. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('demo: swap and buy', (tester) async { + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('FlutterError caught: ${details.exception}'); + }; + + final demo = DemoSession(tester, binding, 'swap_and_buy'); + final flows = CommonTestFlows(tester); + final cases = CommonTestCases(tester); + final dashboard = DashboardPageRobot(tester); + final exchange = ExchangePageRobot(tester); + + demo.begin(); + await flows.startAppFlow(const ValueKey('demo_swap_and_buy')); + await demo.beat(3.0); + + if (DemoConfig.hasSeed && find.text('Restore').evaluate().isNotEmpty) { + await demo.blindfold('restoring demo wallet from seed', () async { + await flows.welcomePageToRestoreWalletThroughSeedsFlow( + DemoConfig.walletType, + DemoConfig.walletSeed, + DemoConfig.pin, + ); + }); + await demo.beat(2.0); + } + + await dashboard.isDashboardPage(); + await demo.beat(1.5); + + // ---------------- Swap -------------------------------------------- + await dashboard.navigateToExchangePage(); + await demo.beat(2.5); + await exchange.isExchangePage(); + demo.guardNoSeedOnScreen(because: 'on exchange page'); + await demo.hold( + 'swap_open', + caption: 'Swap without an account', + seconds: 3.0, + ); + + await exchange.displayBothExchangeCards(); + await demo.beat(1.5); + + // Pick a pair. Wrapped because currency pickers are the most brittle part + // of this screen — a picker change upstream should degrade the clip, not + // kill the whole capture run. + try { + await exchange.selectDepositCurrency(CryptoCurrency.wow); + await demo.beat(1.2); + await exchange.selectReceiveCurrency(CryptoCurrency.btc); + await demo.hold('pair_selected', caption: 'WOW → BTC', seconds: 2.5); + + await exchange.enterDepositAmount(DemoConfig.swapAmount); + await demo.hold( + 'quote', + caption: 'Live rate, no sign-up', + seconds: 4.0, + ); + } catch (e) { + tester.printToConsole('[demo] swap pair selection degraded: $e'); + demo.mark('swap_pair_skipped'); + } + + // Stop here on purpose — no trade is created. + demo.mark('swap_quoted_not_executed'); + await demo.beat(1.5); + + // ---------------- Buy (only if a provider is actually enabled) ------ + await cases.goBack(); + await demo.beat(1.5); + + final buyVisible = find.text('Buy').evaluate().isNotEmpty; + if (buyVisible) { + await dashboard.navigateToBuyPage(); + await demo.beat(2.5); + demo.guardNoSeedOnScreen(because: 'on buy page'); + await demo.hold('buy', caption: 'Buy with card, where available', seconds: 3.5); + } else { + tester.printToConsole( + '[demo] Buy is hidden — no provider key in this build. Skipping, ' + 'which matches what store reviewers will see.', + ); + demo.mark('buy_hidden_no_provider'); + } + + demo.finish(); + }); +} diff --git a/ios/RunnerUITests/HashBagsScreenshotsUITests.swift b/ios/RunnerUITests/HashBagsScreenshotsUITests.swift new file mode 100644 index 000000000..4c50df30c --- /dev/null +++ b/ios/RunnerUITests/HashBagsScreenshotsUITests.swift @@ -0,0 +1,70 @@ +import XCTest + +// fastlane snapshot UI test for Hash Bags. +// +// IMPORTANT — Flutter caveat: Flutter renders the whole UI into ONE native view. +// XCUITest finds "elements" only through the ACCESSIBILITY tree. So for taps to +// work you must either (a) match by the visible label text, or (b) add +// Semantics(identifier: 'send_button') around the widgets you drive. Wallet flows +// (create/restore, PIN) are painful to automate, so the recommended pattern is to +// launch straight into a screenshot-ready DEMO wallet via a launch argument that +// the Flutter app recognizes (see store/SCREENSHOTS.md), then shoot the key +// screens. Adjust identifiers/waits to match the actual UI. +final class HashBagsScreenshotsUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + setupSnapshot(app) // from SnapshotHelper.swift (fastlane snapshot init) + // Tell the Flutter app to restore a demo wallet + skip onboarding so the + // shots show a real dashboard. Handle this arg in Dart (see the guide). + app.launchArguments += ["--dart-define=SCREENSHOT_MODE=true", "FASTLANE_SNAPSHOT"] + app.launch() + } + + func testScreenshots() throws { + // 1) Home / dashboard — wait for it to settle, then shoot. + _ = app.otherElements["home_dashboard"].waitForExistence(timeout: 45) + snapshot("01-Home") + + // 2) Receive + tapFirst(["Receive", "receive_action"]) + snapshot("02-Receive") + goBack() + + // 3) Send + tapFirst(["Send", "send_action"]) + snapshot("03-Send") + goBack() + + // 4) Swap (Trocador) + tapFirst(["Swap", "exchange_action"]) + snapshot("04-Swap") + goBack() + + // 5) Settings + tapFirst(["Settings", "settings_action"]) + snapshot("05-Settings") + } + + // MARK: - helpers + + /// Tap the first element (button or generic) matching any of the given + /// accessibility labels/identifiers. + private func tapFirst(_ names: [String], timeout: TimeInterval = 15) { + for n in names { + let btn = app.buttons[n] + if btn.waitForExistence(timeout: 2) { btn.tap(); return } + let any = app.descendants(matching: .any)[n] + if any.exists { any.tap(); return } + } + XCTFail("Could not find any of: \(names). Add a Semantics(identifier:) to that widget.") + } + + private func goBack() { + let back = app.navigationBars.buttons.element(boundBy: 0) + if back.waitForExistence(timeout: 3) { back.tap() } + } +} diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile new file mode 100644 index 000000000..7e4bdffc0 --- /dev/null +++ b/ios/fastlane/Appfile @@ -0,0 +1,3 @@ +app_identifier("com.suchsoftware.hashwallet") +team_id("32J6BB6VUS") # Apple Developer Team (Such Software LLC) +# apple_id("you@such.software") # fill in only if you later use fastlane to upload diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 000000000..c85f2238c --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,11 @@ +default_platform(:ios) + +platform :ios do + desc "Generate App Store screenshots (iPhone 6.9\" + iPad 13\") via snapshot" + lane :screenshots do + capture_ios_screenshots # reads ios/fastlane/Snapfile + # Optional: add device frames + captions (needs `brew install imagemagick`): + # frame_screenshots(white: true) + UI.success("Screenshots in ios/screenshots/en-US/ — upload to App Store Connect") + end +end diff --git a/ios/fastlane/Snapfile b/ios/fastlane/Snapfile new file mode 100644 index 000000000..536ff39b9 --- /dev/null +++ b/ios/fastlane/Snapfile @@ -0,0 +1,23 @@ +# fastlane snapshot config for Hash Bags App Store screenshots. +# Run on macOS (Mac-mini runner or your Mac): cd ios && fastlane screenshots +# +# These two devices cover Apple's required sizes: +# iPhone 16 Pro Max -> 6.9" (1290x2796 / 1320x2868) [REQUIRED] +# iPad Pro 13" (M4) -> 13" (2064x2752) [required if you ship iPad] +devices([ + "iPhone 16 Pro Max", + "iPad Pro 13-inch (M4)" +]) + +languages(["en-US"]) + +# The Runner scheme's Test action must include the RunnerUITests target +# (see store/SCREENSHOTS.md). only_testing keeps us to just the screenshot test. +scheme("Runner") +only_testing(["RunnerUITests/HashBagsScreenshotsUITests"]) + +output_directory("./screenshots") +clear_previous_screenshots(true) +concurrent_simulators(false) # wallet sync is flaky under parallel sims +override_status_bar(true) # clean status bar: 9:41, full battery/wifi +stop_after_first_error(true) diff --git a/pubspec_description.yaml b/pubspec_description.yaml index e67436035..8e599f390 100644 --- a/pubspec_description.yaml +++ b/pubspec_description.yaml @@ -1,6 +1,6 @@ name: hash_wallet description: Hash Bags — a noncustodial multi-currency wallet by Such Software LLC. -version: 1.0.0+8 +version: 1.0.0+9 publish_to: none environment: diff --git a/scripts/prepare_moneroc.sh b/scripts/prepare_moneroc.sh index a78aa2beb..41c52e7ed 100755 --- a/scripts/prepare_moneroc.sh +++ b/scripts/prepare_moneroc.sh @@ -19,22 +19,59 @@ git fetch -a git checkout bc8d1a0b75b97156d71579581b4cdfe58c777ed2 git reset --hard -# Hash Bags: monero_c bundles monero, wownero, AND zano as sibling -# submodules. Upstream did `git submodule update --init --recursive` which -# pulled ALL three (and their deep transitive submodules — zano alone drags -# down a ~100MB Qt UI tree we don't need). Init only the coins we ship, -# shallow clones, in parallel. -git submodule deinit -f zano 2>/dev/null || true -rm -rf zano -git submodule update --init --recursive --depth 1 --jobs 8 monero wownero +# Hash Bags: every CI workflow (android/ios-sim/ios-testflight/linux/windows) +# consumes PREBUILT .so/.a files from monero_c's release-bundle.zip. None of +# them compile monero_c from source. The only thing they need out of this +# checkout is `git describe --tags`, which resolves the release// path. +# +# So by default we do NOT init the monero/wownero submodules. Besides saving +# ~1GB of source plus deep transitive submodules per CI run, this decouples us +# from where Wownero is hosted: +# +# Wownero left Codeberg in July 2026 (Codeberg announced it would ban crypto +# projects) and remigrated to https://github.com/wownero/wownero. monero_c's +# .gitmodules still points at the dead Codeberg URLs, which now fail with +# "remote: Bye" / HTTP 403. That is what broke the iOS TestFlight build on +# 2026-07-31 — at a step that never needed the source in the first place. +# +# Set MONERO_C_BUILD_FROM_SOURCE=1 only if you genuinely intend to compile +# monero_c yourself. See the caveat below before you do. +if [[ "${MONERO_C_BUILD_FROM_SOURCE:-0}" == "1" ]]; +then + # zano is a sibling submodule we never ship — skip it (its Qt UI tree alone + # is ~100MB). + git submodule deinit -f zano 2>/dev/null || true + rm -rf zano -for coin in monero wownero; -do - if [[ ! -f "$coin/.patch-applied" ]]; - then - ./apply_patches.sh $coin - fi -done -cd .. + # Redirect the dead Codeberg URLs to where the code actually lives now. + # Safe despite being a host swap: both submodules are pinned to exact + # commit SHAs, and git verifies the object hash on fetch, so a different + # host cannot substitute different content under the same SHA. + # + # wownero -> github.com/wownero/wownero (canonical since the July 2026 + # remigration off Codeberg) + # randomwow -> github.com/mrcyjanek/randomwow, which is what monero_c + # itself switches the remote to in apply_patches.sh. Note + # this is NOT github.com/wownero/RandomWOW — that one is a + # fork of tevador/RandomX on a different lineage and does + # not contain the pinned commit. The redirect has to happen + # here because apply_patches.sh only runs AFTER the clone + # that would otherwise fail against Codeberg. + git \ + -c url."https://github.com/wownero/wownero".insteadOf="https://codeberg.org/wownero/wownero" \ + -c url."https://github.com/mrcyjanek/randomwow".insteadOf="https://codeberg.org/wownero/RandomWOW" \ + submodule update --init --recursive --depth 1 --jobs 8 monero wownero -echo "monero_c source prepared". + for coin in monero wownero; + do + if [[ ! -f "$coin/.patch-applied" ]]; + then + ./apply_patches.sh $coin + fi + done + cd .. + echo "monero_c source prepared (full source checkout)." +else + cd .. + echo "monero_c prepared (prebuilt mode — submodules intentionally skipped)." +fi diff --git a/store/SCREENSHOTS.md b/store/SCREENSHOTS.md new file mode 100644 index 000000000..74786a856 --- /dev/null +++ b/store/SCREENSHOTS.md @@ -0,0 +1,94 @@ +# Hash Bags — store screenshots + +Three ways to produce the App Store / Play screenshots. + +**Play (and all marketing video) runs on Linux** via the automated capture +harness — Path C, below. Only the **iOS** sets need macOS, because an iPhone/iPad +Simulator can't run on the Linux box. + +## Required sizes (the Simulator outputs these exactly — don't hand-size) +| Store | Device to capture | Size | Count | +|---|---|---|---| +| App Store | **iPhone 16 Pro Max** (6.9") | 1290×2796 / 1320×2868 | 3–10 (required) | +| App Store | **iPad Pro 13" (M4)** | 2064×2752 | required *if* you ship iPad | +| Google Play | Pixel-class emulator | 1080×1920 (9:16) | 2–8 (required) | + +--- + +## Path A — Manual (recommended for the first submission; ~15 min) +```bash +# iPhone +xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator +flutter run -d "iPhone 16 Pro Max" # from repo root; navigate the app, then: +store/screenshot.sh 01-home +store/screenshot.sh 02-receive +store/screenshot.sh 03-send +store/screenshot.sh 04-swap +store/screenshot.sh 05-settings +# iPad — repeat booted on the iPad sim +xcrun simctl boot "iPad Pro 13-inch (M4)" +flutter run -d "iPad Pro 13-inch (M4)" +OUT_DIR=store/screenshots-ipad store/screenshot.sh 01-home # ...etc + +# Android (Play) — boot a Pixel emulator, run the app, then: +adb exec-out screencap -p > store/screenshots-play/01-home.png +``` +`store/screenshot.sh` captures the booted simulator at the exact store size. + +--- + +## Path B — Automated (fastlane snapshot; repeatable, multi-device/language) +Scaffolded here: `ios/fastlane/{Appfile,Snapfile,Fastfile}` + +`ios/RunnerUITests/HashBagsScreenshotsUITests.swift`. + +**One-time setup (on the Mac):** +1. `cd ios && fastlane snapshot init` — drops `SnapshotHelper.swift` (matched to your fastlane version) into `ios/`. Move it into `ios/RunnerUITests/`. +2. In **Xcode** (`ios/Runner.xcworkspace`): File ▸ New ▸ Target ▸ **UI Testing Bundle**, name it **`RunnerUITests`**, target = `Runner`. Add `SnapshotHelper.swift` + `HashBagsScreenshotsUITests.swift` to it. Then edit the **Runner scheme ▸ Test** action and tick `RunnerUITests`. +3. **Screenshot-ready state:** wallet flows (create/PIN/seed/sync) are painful to drive, so make the app launch straight into a demo wallet when it sees the flag. In Dart, gate on `const bool.fromEnvironment('SCREENSHOT_MODE')` (or check `Platform.environment`/launch args): when true, restore a known demo wallet, skip onboarding, and stub balances. The UITest already passes `--dart-define=SCREENSHOT_MODE=true`. +4. **Accessibility:** the test taps by label/identifier. Add `Semantics(identifier: 'send_action')` (etc.) to the widgets you shoot, or rely on visible text ("Send", "Receive", "Swap", "Settings"). Tune the identifiers in the test. + +**Run:** +```bash +cd ios && fastlane screenshots # → ios/screenshots/en-US/-01-Home.png ... +``` + +--- + +--- + +## Path C — Automated capture harness (Play + all video; runs on Linux) +`tools/demo/` drives the real app on a headless emulator while recording the +screen, then cuts one recording into **both** the screenshots and the video. +See `tools/demo/README.md`. + +```bash +cp tools/demo/demo.env.example tools/demo/demo.env # fill in; gitignored +export BW_SESSION=$(bw unlock --raw) # demo seed from Vaultwarden + +tools/demo/build-apk.sh # debug APK via the CI container (Flutter 3.32.0) +tools/demo/capture.sh boot # creates/boots a 1080x1920 AVD +tools/demo/capture.sh all # record all scenarios, cut, shoot, assemble +``` + +Why it exists, beyond convenience: +- **Sizing.** It builds its own AVD at exactly 1080×1920 (16:9). The stock + "Medium Phone" AVD is 1080×2400 = **2.22:1**, which Play rejects (max 2:1). +- **Consistency.** Screenshots are frames of the same recording the trailer is + cut from, taken at the timestamps the scenario reports. +- **Seed safety.** Frames containing a mnemonic are bracketed by the scenario + and excised automatically; the tooling refuses to emit a publishable master + if any clip wasn't verified. The demo wallet holds real funds. +- **Apple rules.** The `apple-preview` profile force-disables touch indicators, + captions and music, and enforces Apple's 15–30s window. + +Scenarios cover the core store set, onboarding/create, restore-from-seed, and +swap/buy. + +--- + +## Upload +- **App Store Connect** ▸ your app ▸ version ▸ Previews and Screenshots → drag the 6.9" iPhone (and 13" iPad) sets. +- **Play Console** ▸ Store listing ▸ Phone screenshots → the 1080×1920 set; plus the **icon** (`store/play-icon-512.png`) and **feature graphic** (`store/play-feature-graphic-1024x500.png`). + +> Reality check: for a *first* launch, Path A (manual) is faster. Path B pays off +> when you re-shoot across versions/locales. Either way it's a Mac task. diff --git a/store/feature-graphic.svg b/store/feature-graphic.svg new file mode 100644 index 000000000..cf079a125 --- /dev/null +++ b/store/feature-graphic.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + # + + + + + Hash Bags + Open-source, non-custodial wallet + Wownero · Monero · Bitcoin · Litecoin · and more + + diff --git a/store/play-feature-graphic-1024x500.png b/store/play-feature-graphic-1024x500.png new file mode 100644 index 000000000..120bdfa79 Binary files /dev/null and b/store/play-feature-graphic-1024x500.png differ diff --git a/store/play-icon-512.png b/store/play-icon-512.png new file mode 100644 index 000000000..9eefce44d Binary files /dev/null and b/store/play-icon-512.png differ diff --git a/store/screenshot.sh b/store/screenshot.sh new file mode 100755 index 000000000..a18147123 --- /dev/null +++ b/store/screenshot.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Manual screenshot helper (the fast path for a first submission). +# Boot the target simulator, run the app, navigate to a screen, then run this to +# capture it at App-Store-exact dimensions. +# +# Usage: ./screenshot.sh # captures the booted simulator +# OUT_DIR=~/hb-shots ./screenshot.sh 01-home +# +# Recommended flow (on the Mac): +# xcrun simctl boot "iPhone 16 Pro Max"; open -a Simulator +# (cd .. && flutter run -d "iPhone 16 Pro Max") # navigate to each screen, then: +# ./screenshot.sh 01-home ; ./screenshot.sh 02-receive ; ... +# # repeat booted on "iPad Pro 13-inch (M4)" for the iPad set. +set -euo pipefail +name="${1:?usage: screenshot.sh }" +out="${OUT_DIR:-./screenshots-manual}" +mkdir -p "$out" +xcrun simctl io booted screenshot "$out/$name.png" +dims=$(python3 -c "import struct;d=open('$out/$name.png','rb').read(26);print('x'.join(map(str,struct.unpack('>II',d[16:24]))))" 2>/dev/null || echo "?") +echo "saved $out/$name.png ($dims)" diff --git a/tools/demo/README.md b/tools/demo/README.md new file mode 100644 index 000000000..caf78893f --- /dev/null +++ b/tools/demo/README.md @@ -0,0 +1,129 @@ +# Hash Bags capture harness + +Drives the real app through real wallet flows on an emulator while recording +the screen, then cuts the recording into store screenshots and marketing video. + +One recording produces **both** deliverables. Screenshots are pulled from the +same footage as the video, at the timestamps the scenario reports, so a listing +can't drift out of sync with its own trailer. + +``` +integration_test/demo/scenarios/*.dart drive the app, emit timing marks + │ + ▼ +tools/demo/capture.sh record adb screenrecord + flutter drive + │ + ├── marks.py cut → trims lead-in, EXCISES seed frames + ├── marks.py shots → store screenshots at native 1080x1920 + └── reel.py → branded master (social / apple-preview / play) +``` + +## Quick start + +```bash +cp tools/demo/demo.env.example tools/demo/demo.env # fill in, gitignored +export BW_SESSION=$(bw unlock --raw) # Vaultwarden + +tools/demo/build-apk.sh # optional but recommended — see below +tools/demo/capture.sh boot # 1080x1920 headless AVD +tools/demo/capture.sh all # record every scenario + assemble a reel +``` + +`build-apk.sh` is **not** a strict prerequisite — `capture.sh record` runs +`flutter drive`, which builds its own instrumented debug APK. Run it first +anyway on a fresh checkout: it validates the entire toolchain in one shot, +mints the local signing key, and warms the Gradle cache, so your first `record` +doesn't spend twenty minutes compiling only to hit a config error. It also +leaves you a sideloadable APK. + +Individual steps: + +```bash +tools/demo/capture.sh record core_store_set +tools/demo/capture.sh shots core_store_set +tools/demo/capture.sh reel social +tools/demo/capture.sh reel apple-preview --overlays none +``` + +Output lands in `~/Build/hash-wallet/demo/` (never in the repo), split into +`raw/`, `cut/`, `screenshots/` and `publish/`. + +## Scenarios + +| Scenario | Shows | Notes | +|---|---|---| +| `core_store_set` | Dashboard, Receive, Send, Wallets, Security | The store screenshot set. Send is **composed, never broadcast**. | +| `onboarding_create` | Welcome → PIN → seed → dashboard | Creates a throwaway wallet. Never fund it. | +| `restore_from_seed` | Restore from mnemonic → syncing | Uses the demo wallet seed. | +| `swap_and_buy` | Trocador swap quote, Buy | Stops **before** creating a trade. Buy is skipped when no provider key is present. | + +Scenarios reuse the existing page robots in `integration_test/robots/`. They +are not correctness tests — they add pacing (`beat`, `hold`, `typeSlowly`) so +the footage is watchable, and timing marks so captions land on the right frame. + +## Seed safety + +The demo wallet is a **real wallet with a real balance** — that is what makes +the screenshots honest, and it is also why the harness is paranoid: + +- The seed lives in Vaultwarden. It is fetched at capture time into one shell + variable and passed as a `--dart-define`. It is never written to the repo, + to `.secrets.g.dart`, or to `demo.env`. +- `DemoSession.guardNoSeedOnScreen()` fails the run if a mnemonic is rendered + while recording. It checks for the known demo seed (whole and word-scattered) + *and* for the generic shape of a mnemonic, so a freshly generated seed is + caught too. +- Flows that must pass through a seed screen wrap it in `blindfold()`. Those + spans are reported to the host and **excised from the video**, padded by + 0.6s on each side. Screenshots use the same padding. +- `marks.py manifest` **refuses** to build a publishable master if any clip was + cut without marks, since blindfold spans could not have been excised. + +Do not lift those guards to get a nicer shot. + +## Overlays and the Apple App Preview + +Touch indicators are the system-level ripple (`settings put system +show_touches 1`), not a composited fake cursor — so they appear in the +recording exactly as a user would see them. + +They are **on by default**, and **force-disabled for `apple-preview`**. +`capture.sh reel apple-preview` refuses to run against footage recorded with +`--overlays taps`. The App Preview shows the app as-is: no touch indicators, +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 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. + +iOS App Store screenshots and the App Preview still have to come from a Mac — +see `store/SCREENSHOTS.md` for the fastlane snapshot path. + +## Why a container for the build + +The repo pins Flutter 3.32.0. A host Flutter 3.38+ **cannot resolve the +dependency set at all** — `hive_generator` wants an analyzer that still ships +the `macros` package, which newer Dart SDKs removed. `build-apk.sh` therefore +builds inside the same image CI uses. + +It also mints a **throwaway** signing key: `android/app/build.gradle` wires the +debug build type to `signingConfigs.release`, so a local build cannot even +configure without a keystore. A capture APK must never be signed with the Play +upload key, and minting a separate one keeps that impossible by construction. + +## Reusing this elsewhere + +`reel.py` imports `such_graphics.video_transitions` (tuned xfade presets) and +`such_graphics.video_qa` (decode-level "did this render black" gate) from +`~/src/such-graphics` when that repo is checked out, and falls back to a local +subset when it is not — this repo is public and must build without it. Point +`SUCH_GRAPHICS_HOME` elsewhere if needed. + +The music-bed handling (random offset per render, so repeat uploads of the same +variant don't feel identical) is lifted from +`~/src/medusa-multi-tenant-platform/tools/marketing-video/build.mjs`. diff --git a/tools/demo/build-apk.sh b/tools/demo/build-apk.sh new file mode 100755 index 000000000..85712c5b8 --- /dev/null +++ b/tools/demo/build-apk.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Build a Hash Bags APK locally using the SAME container image CI uses. +# +# Why a container: this repo pins Flutter 3.32.0. A newer host Flutter (3.38+) +# cannot resolve the dependency set at all -- hive_generator wants an analyzer +# that still ships the `macros` package, which newer Dart SDKs removed. Rather +# than fight that, we build inside +# ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly +# which is exactly what .github/workflows/build-android.yml uses. +# +# Usage: +# tools/demo/build-apk.sh # debug, x86_64 (emulator capture) +# BUILD_MODE=release ABI=arm64 tools/demo/build-apk.sh +# SKIP_DEPS=1 tools/demo/build-apk.sh # re-build only, deps already fetched +# +# The container runs as root (FLUTTER_ROOT lives under /root in the image), so +# the script chowns everything back to the invoking user on exit -- otherwise +# you get root-owned build artifacts scattered through your worktree. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +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}" +SKIP_DEPS="${SKIP_DEPS:-0}" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +case "$ABI" in + x86_64) TARGET_PLATFORM="android-x64" ;; + arm64) TARGET_PLATFORM="android-arm64" ;; + all) TARGET_PLATFORM="" ;; + *) echo "ABI must be x86_64 | arm64 | all" >&2; exit 1 ;; +esac + +echo "==> image $IMAGE" +echo "==> mode $BUILD_MODE" +echo "==> abi $ABI (${TARGET_PLATFORM:-universal})" +echo "==> repo $REPO_ROOT" + +docker run --rm \ + --network host \ + -v "$REPO_ROOT":/work \ + -w /work \ + -e SKIP_DEPS="$SKIP_DEPS" \ + -e BUILD_MODE="$BUILD_MODE" \ + -e TARGET_PLATFORM="$TARGET_PLATFORM" \ + -e HOST_UID="$HOST_UID" \ + -e HOST_GID="$HOST_GID" \ + "$IMAGE" bash -c ' +set -euo pipefail +# Chown back on ANY exit path so a failed build does not leave a root-owned +# worktree behind. +trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT + +git config --global --add safe.directory "*" + +step() { echo; echo "########## $* ##########"; } + +if [[ "$SKIP_DEPS" != "1" ]]; then + step "torch_dart prebuilt" + pushd scripts >/dev/null + if [[ ! -d torch_dart ]]; then + wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz + mkdir -p torch_dart && tar -xzf torch_dart.tar.gz -C torch_dart && rm torch_dart.tar.gz + else echo "already present"; fi + popd >/dev/null + + step "reown_flutter prebuilt" + pushd scripts >/dev/null + if [[ ! -d reown_flutter ]]; then + wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz + mkdir -p reown_flutter && tar -xzf reown_flutter.tar.gz -C reown_flutter && rm reown_flutter.tar.gz + else echo "already present"; fi + popd >/dev/null + + step "bitbox flutter" + # Guard on the ARTIFACT, not the directory. A half-finished checkout with no + # api.aar looks "done" by directory alone, and gradle then fails deep into + # assembleDebug with an opaque "Could not find :api:". + pushd scripts >/dev/null + if [[ -e bitbox_flutter/go/api/api.aar ]]; then + echo "api.aar already built" + else + ./build_bitbox_flutter.sh + fi + popd >/dev/null + + step "monero_c prebuilt .so bundle" + ./scripts/prepare_moneroc.sh + MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) + echo "monero_c TAG: $MONERO_C_TAG" + BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG" + mkdir -p "$BUNDLE_DIR" + if [[ ! -d "$BUNDLE_DIR/x86_64-linux-android" ]]; then + pushd "$BUNDLE_DIR" >/dev/null + wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + unzip -q release-bundle.zip && rm release-bundle.zip + popd >/dev/null + else echo "bundle already extracted"; fi + + step "stage jniLibs" + declare -A ABI_MAP=( + [aarch64-linux-android]=arm64-v8a + [armv7a-linux-androideabi]=armeabi-v7a + [x86_64-linux-android]=x86_64 + ) + for target in "${!ABI_MAP[@]}"; do + abi="${ABI_MAP[$target]}" + [[ -d "$BUNDLE_DIR/$target" ]] || { echo "SKIP $target"; continue; } + mkdir -p "android/app/src/main/jniLibs/$abi" + for coin in monero wownero; do + src="$BUNDLE_DIR/$target/lib${coin}_wallet2_api_c.so" + [[ -f "$src" ]] && cp -f "$src" "android/app/src/main/jniLibs/$abi/" + done + done + find android/app/src/main/jniLibs -name "*.so" | sort + + step "reown yttrium uniffi bindings" + # The prebuilt reown_flutter tarball that build-android.yml fetches does NOT + # contain the generated Kotlin uniffi bindings. Without them, gradle dies + # deep inside assembleDebug with a wall of + # e: ...Extensions.kt: Unresolved reference "uniffi" + # which says nothing about the real cause. + # + # Upstream generates them in a SEPARATE image layer + # (scripts/android/docker/Dockerfile.reown runs build_reown_deps.sh), which + # the GitHub workflow never runs -- it assumes the runner already has them. + # That is why the Gitea mac-mini runner builds green while a clean + # replication of build-android.yml does not. + if find scripts/reown_flutter -type d -name uniffi 2>/dev/null | grep -q .; then + echo "uniffi bindings present" + 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)" + ./scripts/android/build_reown_deps.sh || { + echo + echo "FATAL: could not generate the reown uniffi bindings." + echo "Options:" + echo " 1. Build the upstream deps image: scripts/android/docker/build.sh" + echo " 2. Use an APK from the Gitea runner, which already has them:" + echo " https://git.such.software/Builds/hash-wallet/actions" + exit 1 + } + else + echo "FATAL: no uniffi bindings and no build_reown_deps.sh to generate them." >&2 + exit 1 + fi + + step "android configure (generates pubspec.yaml, manifest, app_properties)" + pushd scripts/android >/dev/null + source ./app_env.sh cakewallet + ./app_config.sh + popd >/dev/null +fi + +step "local throwaway signing key" +# android/app/build.gradle wires the DEBUG build type to signingConfigs.release +# (see `debug { signingConfig signingConfigs.release }`). With no key.properties +# present, `file(null)` blows up gradle configuration before a single source +# file compiles -- so even a debug build needs a keystore. +# +# CI supplies the real Play upload key from secrets. Locally we mint a +# throwaway instead: a capture/demo APK must never be signed with the upload +# key, and this keeps that impossible by construction. +if [[ ! -f android/key.properties ]]; then + keytool -genkeypair -v \ + -keystore android/app/debug-capture.jks \ + -storepass hashbags -keypass hashbags \ + -alias hashbags-debug \ + -keyalg RSA -keysize 2048 -validity 3650 \ + -dname "CN=Hash Bags Capture, OU=Demo, O=Such Software LLC, C=US" 2>/dev/null + cat > android/key.properties < artifacts:" +ls -la build/app/outputs/flutter-apk/*.apk +' + +# scripts/android/app_icon.sh points assets/images/app_logo.png at an ABSOLUTE +# path built from `pwd`, which inside the container is /work. That leaves a +# tracked file replaced by a symlink that dangles on the host and shows up as a +# typechange in `git status`. Put it back. +if [[ -L "$REPO_ROOT/assets/images/app_logo.png" ]]; then + git -C "$REPO_ROOT" checkout -- assets/images/app_logo.png 2>/dev/null \ + && echo "restored assets/images/app_logo.png (container symlink removed)" +fi + +echo +echo "Done. APK(s) under $REPO_ROOT/build/app/outputs/flutter-apk/" diff --git a/tools/demo/capture.sh b/tools/demo/capture.sh new file mode 100755 index 000000000..e18bc6d89 --- /dev/null +++ b/tools/demo/capture.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# Hash Bags capture harness — drive real wallet flows while recording screen. +# +# One recording produces BOTH deliverables: the video (trimmed and captioned +# from the scenario's timing marks) and the store screenshots (frames pulled at +# those same marks, at native device resolution). Shooting them separately is +# how listings end up inconsistent. +# +# tools/demo/capture.sh boot +# tools/demo/capture.sh record core_store_set +# tools/demo/capture.sh shots core_store_set +# tools/demo/capture.sh reel social +# tools/demo/capture.sh all +# +# Options: +# --target android|linux where to run (default: android) +# --overlays taps|none touch indicators (default: taps) +# --avd NAME emulator AVD (default: HashBags_Capture) +# --serial NAME adb serial (default: autodetect) +# +# OVERLAYS AND THE APPLE APP PREVIEW +# The `apple-preview` reel profile force-disables overlays and refuses to run +# with --overlays=taps. The App Preview must be the app as a user sees it, +# with no synthetic touch indicators composited on top. Everything else +# (social cuts, Play listing video, docs GIFs) keeps taps on by default, +# because there they genuinely help a viewer follow the interaction. +# +# SEED SAFETY +# Scenarios bracket every frame containing a mnemonic in a `blindfold` span +# and report those spans back through the driver. This script refuses to +# promote any master to the publishable directory while a blindfold span is +# still inside the cut. The demo wallet holds real funds — that guard is not +# optional and should not be edited out. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +# shellcheck source=lib/secrets.sh +source "$HERE/lib/secrets.sh" +demo_load_env "$HERE/demo.env" + +IMAGE="${CI_IMAGE:-ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly}" +ANDROID_SDK="${ANDROID_HOME:-$HOME/Android/Sdk}" +ADB="$ANDROID_SDK/platform-tools/adb" +EMULATOR_BIN="$ANDROID_SDK/emulator/emulator" + +TARGET="android" +OVERLAYS="taps" +AVD="${DEMO_AVD:-HashBags_Capture}" +SERIAL="${DEMO_SERIAL:-}" + +# Build outputs live outside the repo, per the workstation conventions. +OUT_ROOT="${DEMO_OUT_ROOT:-$HOME/Build/hash-wallet/demo}" +RAW_DIR="$OUT_ROOT/raw" # untrimmed recordings + driver JSON +CUT_DIR="$OUT_ROOT/cut" # trimmed, blindfold-free clips +SHOT_DIR="$OUT_ROOT/screenshots" # store screenshots +PUB_DIR="$OUT_ROOT/publish" # finished masters — only safe material lands here + +SCENARIOS=(core_store_set onboarding_create restore_from_seed swap_and_buy) + +# On-device path for the in-flight recording. +DEV_CAPTURE=/data/local/tmp/hb-capture.mp4 + +log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# arg parsing +# --------------------------------------------------------------------------- +CMD="${1:-help}"; shift || true +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --overlays) OVERLAYS="$2"; shift 2 ;; + --avd) AVD="$2"; shift 2 ;; + --serial) SERIAL="$2"; shift 2 ;; + -h|--help) CMD="help"; shift ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done +set -- "${POSITIONAL[@]+"${POSITIONAL[@]}"}" + +# --------------------------------------------------------------------------- +# device helpers +# --------------------------------------------------------------------------- +# Wait up to `timeout` seconds for a device to appear. A freshly launched +# emulator takes appreciably longer than a fixed sleep to register with adb, +# and the port varies (5554, 5556, ...) depending on what else is running. +detect_serial() { + [[ -n "$SERIAL" ]] && return 0 + local timeout="${1:-90}" waited=0 + while (( waited < timeout )); do + SERIAL="$("$ADB" devices | awk '$2=="device" {print $1; exit}')" + [[ -n "$SERIAL" ]] && { log "device: $SERIAL"; return 0; } + sleep 2; waited=$((waited + 2)) + done + die "no device appeared within ${timeout}s. Run: tools/demo/capture.sh boot" +} + +adbs() { "$ADB" -s "$SERIAL" "$@"; } + +wait_for_boot() { + log "waiting for device boot" + "$ADB" -s "$SERIAL" wait-for-device + local tries=0 + until [[ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do + ((tries++)); [[ $tries -gt 180 ]] && die "device did not finish booting" + sleep 2 + done + log "device booted ($SERIAL)" +} + +cmd_boot() { + if "$ADB" devices | grep -q "device$"; then + log "a device is already booted"; detect_serial; return 0 + fi + log "booting AVD $AVD (headless)" + # -feature -GLDMA: with swiftshader_indirect on a headless host, the guest + # graphics stack otherwise aborts on + # "Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma" + # which kills screencap and screenrecord — i.e. exactly the two things this + # harness needs. Disabling the DMA colour-buffer path costs a little capture + # throughput and makes both work. + nohup "$EMULATOR_BIN" -avd "$AVD" -no-window -no-audio -no-boot-anim \ + -gpu swiftshader_indirect -feature -GLDMA -no-snapshot-save \ + > "$OUT_ROOT/emulator.log" 2>&1 & + sleep 5 + detect_serial + wait_for_boot +} + +set_overlays() { + # Native touch indicators. Far cleaner than compositing a fake cursor: this + # is the real system-level ripple, drawn by SurfaceFlinger, so it appears in + # screenrecord exactly as a user would see it. + local on="$1" + if [[ "$on" == "taps" ]]; then + log "touch indicators ON" + adbs shell settings put system show_touches 1 || true + adbs shell settings put system pointer_location 0 || true + else + log "touch indicators OFF" + adbs shell settings put system show_touches 0 || true + adbs shell settings put system pointer_location 0 || true + fi +} + +prep_device_for_camera() { + # A clean status bar. Google's own demo-mode API — no root needed. + adbs shell settings put global sysui_demo_allowed 1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command enter >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 >/dev/null 2>&1 || true + adbs shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false >/dev/null 2>&1 || true + # Kill animations-off if a previous test run set them; we WANT animations. + adbs shell settings put global window_animation_scale 1 || true + adbs shell settings put global transition_animation_scale 1 || true + adbs shell settings put global animator_duration_scale 1 || true +} + +exit_demo_mode() { + adbs shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1 || true +} + +# --------------------------------------------------------------------------- +# record +# --------------------------------------------------------------------------- +cmd_record() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh record (${SCENARIOS[*]})" + [[ -f "$REPO_ROOT/integration_test/demo/scenarios/$scenario.dart" ]] \ + || die "no such scenario: $scenario" + + mkdir -p "$RAW_DIR" + detect_serial + wait_for_boot + set_overlays "$OVERLAYS" + prep_device_for_camera + + # Pull the demo seed only if the scenario needs it. + local seed="" + if [[ "$scenario" != "onboarding_create" ]]; then + if seed="$(demo_secret seed 2>/dev/null)"; then + log "demo wallet seed loaded from Vaultwarden ($(echo "$seed" | wc -w) words)" + else + warn "no demo seed available — scenario will run against whatever wallet the device already has" + seed="" + fi + fi + local send_addr="${DEMO_SEND_ADDRESS:-}" + + local dev_epoch_start + dev_epoch_start="$(adbs shell date +%s%3N | tr -d '\r')" + + log "starting screenrecord" + # /data/local/tmp, not /sdcard: the emulator's emulated-storage FUSE layer + # is flaky headless ("Transport endpoint is not connected"), and screenrecord + # then fails to open its output. /data/local/tmp is a plain filesystem that + # adb can always read and write. + adbs shell rm -f "$DEV_CAPTURE" || true + adbs shell screenrecord --size 1080x1920 --bit-rate 12000000 \ + --time-limit 180 "$DEV_CAPTURE" & + local rec_pid=$! + sleep 2 # let the encoder actually start before the app launches + + log "driving scenario: $scenario" + set +e + docker run --rm --network host \ + -v "$REPO_ROOT":/work -w /work \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -e DEMO_SEED="$seed" -e DEMO_SEND_ADDR="$send_addr" \ + -e SCENARIO="$scenario" -e SERIAL="$SERIAL" \ + "$IMAGE" bash -c ' + trap "chown -R ${HOST_UID}:${HOST_GID} /work 2>/dev/null || true" EXIT + git config --global --add safe.directory "*" + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/demo/scenarios/'"$scenario"'.dart \ + -d "$SERIAL" \ + --dart-define-from-file=env.json \ + --dart-define=DEMO_WALLET_SEED="$DEMO_SEED" \ + --dart-define=DEMO_SEND_ADDRESS="$DEMO_SEND_ADDR" + ' 2>&1 | tee "$RAW_DIR/$scenario.drive.log" + local drive_rc=${PIPESTATUS[0]} + set -e + + log "stopping screenrecord" + adbs shell pkill -INT screenrecord || true + wait "$rec_pid" 2>/dev/null || true + sleep 3 # screenrecord needs a moment to finalise the moov atom + + adbs pull "$DEV_CAPTURE" "$RAW_DIR/$scenario.mp4" >/dev/null \ + || die "could not pull recording — did screenrecord start?" + adbs shell rm -f "$DEV_CAPTURE" || true + + # Driver output: marks + blindfold spans. + if [[ -f "$REPO_ROOT/integration_test/integration_response_data.json" ]]; then + cp "$REPO_ROOT/integration_test/integration_response_data.json" \ + "$RAW_DIR/$scenario.marks.json" + else + warn "no driver output — captions and screenshots will be unavailable" + fi + echo "$dev_epoch_start" > "$RAW_DIR/$scenario.recstart" + + exit_demo_mode + if [[ $drive_rc -ne 0 ]]; then + warn "scenario exited non-zero ($drive_rc); the recording was still saved." + warn "check $RAW_DIR/$scenario.drive.log" + fi + log "raw recording: $RAW_DIR/$scenario.mp4" + cmd_cut "$scenario" +} + +# --------------------------------------------------------------------------- +# cut — trim launch/install lead-in and excise every blindfold span +# --------------------------------------------------------------------------- +cmd_cut() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh cut " + local raw="$RAW_DIR/$scenario.mp4" + [[ -f "$raw" ]] || die "no raw recording for $scenario" + mkdir -p "$CUT_DIR" + + python3 "$HERE/marks.py" cut \ + --raw "$raw" \ + --marks "$RAW_DIR/$scenario.marks.json" \ + --recstart "$RAW_DIR/$scenario.recstart" \ + --out "$CUT_DIR/$scenario.mp4" \ + --manifest "$CUT_DIR/$scenario.manifest.json" + log "cut clip: $CUT_DIR/$scenario.mp4" +} + +# --------------------------------------------------------------------------- +# shots — store screenshots pulled from the recording at mark timestamps +# --------------------------------------------------------------------------- +cmd_shots() { + local scenario="${1:-}" + [[ -n "$scenario" ]] || die "usage: capture.sh shots " + mkdir -p "$SHOT_DIR" + python3 "$HERE/marks.py" shots \ + --raw "$RAW_DIR/$scenario.mp4" \ + --marks "$RAW_DIR/$scenario.marks.json" \ + --recstart "$RAW_DIR/$scenario.recstart" \ + --outdir "$SHOT_DIR" + log "screenshots: $SHOT_DIR" +} + +# --------------------------------------------------------------------------- +# reel — assemble a finished master +# --------------------------------------------------------------------------- +cmd_reel() { + local profile="${1:-social}" + mkdir -p "$PUB_DIR" + + if [[ "$profile" == "apple-preview" && "$OVERLAYS" == "taps" ]]; then + die "apple-preview requires --overlays none. Re-record with: + tools/demo/capture.sh record --overlays none +An App Preview must show the app as-is, without composited touch indicators." + fi + + local manifest="$PUB_DIR/$profile.manifest.json" + local music_args=() + # apple-preview ignores music by design; reel.py enforces that too. + if [[ -n "${DEMO_MUSIC:-}" && "$profile" != "apple-preview" ]]; then + if [[ -f "$DEMO_MUSIC" ]]; then + music_args=(--music "$DEMO_MUSIC") + else + warn "DEMO_MUSIC set but not found: $DEMO_MUSIC — rendering silent" + fi + fi + python3 "$HERE/marks.py" manifest \ + --cutdir "$CUT_DIR" --profile "$profile" --out "$manifest" \ + --scenarios "${SCENARIOS[*]}" "${music_args[@]+"${music_args[@]}"}" + + python3 "$HERE/reel.py" \ + --manifest "$manifest" --profile "$profile" \ + -o "$PUB_DIR/hashbags-$profile.mp4" + log "master: $PUB_DIR/hashbags-$profile.mp4" +} + +cmd_all() { + cmd_boot + for s in "${SCENARIOS[@]}"; do + cmd_record "$s" || warn "scenario $s failed; continuing" + cmd_shots "$s" || warn "shots for $s failed; continuing" + done + cmd_reel social + log "done. publishable output under $PUB_DIR" +} + +cmd_help() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +mkdir -p "$OUT_ROOT" + +if [[ "$TARGET" == "linux" ]]; then + die "--target linux is not implemented. + +The Linux desktop build DOES render the phone UI (linux/my_application.cc +forces a 720x1280 window when DESKTOP_FORCE_MOBILE is set), and Xvfb + +ffmpeg x11grab are available on this box, so it is a reasonable fast-iteration +path. What it needs first: + 1. ./configure_hash_wallet.sh linux (builds the x86_64-linux-gnu monero_c libs) + 2. a recorder branch here using: Xvfb :99 + DESKTOP_FORCE_MOBILE=Y + flutter drive -d linux + ffmpeg -f x11grab -video_size 720x1280 -i :99 + +It is deliberately NOT wired up half-way: desktop chrome, fonts and insets +differ from the phone build, so Linux footage must never be used for a store +listing. Use --target android for anything shippable." +elif [[ "$TARGET" != "android" ]]; then + die "--target must be 'android' (see help)" +fi + +case "$CMD" in + boot) cmd_boot ;; + record) cmd_record "${1:-}" ;; + cut) cmd_cut "${1:-}" ;; + shots) cmd_shots "${1:-}" ;; + reel) cmd_reel "${1:-social}" ;; + all) cmd_all ;; + help|*) cmd_help ;; +esac diff --git a/tools/demo/demo.env.example b/tools/demo/demo.env.example new file mode 100644 index 000000000..48a7a86fc --- /dev/null +++ b/tools/demo/demo.env.example @@ -0,0 +1,58 @@ +# Hash Bags capture harness — local configuration TEMPLATE. +# +# Copy to tools/demo/demo.env (which is gitignored) and fill in. +# Per ~/src/docs/engineering/developer-workstations.md: human credentials live +# in Vaultwarden; ignored development values sit beside the worktree; only +# reviewed templates get committed. +# +# cp tools/demo/demo.env.example tools/demo/demo.env +# +# NEVER put the demo wallet seed in this file. It is fetched from Vaultwarden +# at capture time. NEVER put the Vaultwarden hostname in a committed file +# either — that is infrastructure detail and does not belong in a public repo. + +# --------------------------------------------------------------------------- +# Vaultwarden +# --------------------------------------------------------------------------- +# Your self-hosted server URL. Set it here (this file is gitignored), then: +# bw config server "$VAULTWARDEN_SERVER" +# bw login +# export BW_SESSION=$(bw unlock --raw) +VAULTWARDEN_SERVER=https://vault.example.internal + +# Name of the Vaultwarden item holding the demo wallet. The seed may live in +# the item's notes, its password field, or a custom field named `seed`. +VAULTWARDEN_ITEM=hash-bags-demo-wallet + +# --------------------------------------------------------------------------- +# Demo wallet presentation +# --------------------------------------------------------------------------- +# Which chain is the face of the app in store material. Hash Bags is +# Wownero-first. One of: wownero | monero | bitcoin | litecoin +DEMO_WALLET_TYPE=wownero + +# Destination shown on the Send screen. This APPEARS ON CAMERA — use an +# address you are happy to publish. Defaulting it to the demo wallet's own +# receive address avoids putting a stranger's address in a store listing. +DEMO_SEND_ADDRESS= + +# Amounts typed on camera. Nothing is ever broadcast by the scenarios. +DEMO_SEND_AMOUNT=0.5 +DEMO_SWAP_AMOUNT=10 + +# PIN entered during capture (digits only). +DEMO_PIN=0801 + +# --------------------------------------------------------------------------- +# Capture targets +# --------------------------------------------------------------------------- +# AVD created by the harness at exactly 1080x1920 (16:9). Do not point this at +# a 1080x2400 phone AVD: that is 2.22:1 and Google Play rejects phone +# screenshots with an aspect ratio above 2:1. +DEMO_AVD=HashBags_Capture + +# Where captures and renders land. Kept out of the repo on purpose. +DEMO_OUT_ROOT=$HOME/Build/hash-wallet/demo + +# Optional music bed baked into the `social` reel. Ignored by apple-preview. +# DEMO_MUSIC=/home/jw/src/meatspace/assets/music/instrumental_vibey.mp3 diff --git a/tools/demo/lib/secrets.sh b/tools/demo/lib/secrets.sh new file mode 100755 index 000000000..0dea9e7e3 --- /dev/null +++ b/tools/demo/lib/secrets.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Demo-wallet secret retrieval from the self-hosted Vaultwarden. +# +# The demo wallet is a REAL wallet with a REAL balance -- that is what makes +# the screenshots honest. Its seed therefore never lands in this repo, in +# .secrets.g.dart, in a shell history, or in a committed env file. It is +# fetched at capture time, held in a shell variable for the life of one +# capture run, and passed to the app as a --dart-define. +# +# Sourced by capture.sh; not meant to be run directly. +# +# Configuration lives in tools/demo/demo.env (gitignored). Copy +# demo.env.example and fill it in. Per ~/src/docs/engineering/ +# developer-workstations.md: human credentials in Vaultwarden, ignored +# development values beside the worktree, only reviewed templates committed. + +# --------------------------------------------------------------------------- +# bw CLI bootstrap +# --------------------------------------------------------------------------- +demo_secrets_check() { + if ! command -v bw >/dev/null 2>&1; then + cat >&2 <<'EOF' +ERROR: the Bitwarden CLI (`bw`) is not installed, so the demo wallet seed +cannot be fetched from Vaultwarden. + +Install one of: + npm install -g @bitwarden/cli + sudo snap install bw + +Then point it at the self-hosted server and log in (do this once): + bw config server "$VAULTWARDEN_SERVER" # value lives in tools/demo/demo.env + bw login + export BW_SESSION=$(bw unlock --raw) + +Alternatively, for a one-off run you can bypass Vaultwarden entirely: + DEMO_WALLET_SEED='word word ...' tools/demo/capture.sh record core_store_set + +...but do not put that in a file, and do not leave it in your shell history +(prefix the command with a space if HISTCONTROL=ignorespace). +EOF + return 1 + fi + return 0 +} + +# demo_secret +# +# Resolution order: +# 1. An already-exported env var of the same name (CI / one-off override). +# 2. A custom field on the Vaultwarden item named $VAULTWARDEN_ITEM. +# 3. The item's password/notes, when the field name is `seed`. +demo_secret() { + local field="$1" + local envname + envname="$(echo "$field" | tr '[:lower:]-' '[:upper:]_')" + + # 1. explicit override + local existing="${!envname:-}" + if [[ -n "$existing" ]]; then + printf '%s' "$existing" + return 0 + fi + + demo_secrets_check || return 1 + + if [[ -z "${BW_SESSION:-}" ]]; then + echo "ERROR: BW_SESSION is not set. Unlock the vault first:" >&2 + echo " export BW_SESSION=\$(bw unlock --raw)" >&2 + return 1 + fi + + local item="${VAULTWARDEN_ITEM:-hash-bags-demo-wallet}" + local json + if ! json="$(bw get item "$item" 2>/dev/null)"; then + echo "ERROR: no Vaultwarden item named '$item'." >&2 + echo "Create it and store the demo wallet seed there, or set" >&2 + echo "VAULTWARDEN_ITEM in tools/demo/demo.env." >&2 + return 1 + fi + + # Prefer an explicitly named custom field. + local value + value="$(printf '%s' "$json" | python3 -c ' +import json, sys +field = sys.argv[1] +data = json.load(sys.stdin) +for f in (data.get("fields") or []): + if (f.get("name") or "").lower() == field.lower(): + print(f.get("value") or "", end="") + sys.exit(0) +# fall back to notes / password for the seed itself +if field.lower() in ("seed", "demo_wallet_seed"): + print((data.get("notes") or (data.get("login") or {}).get("password") or ""), end="") +' "$field")" + + if [[ -z "$value" ]]; then + echo "ERROR: Vaultwarden item '$item' has no field '$field'." >&2 + return 1 + fi + printf '%s' "$value" +} + +# Load the gitignored local config if present. +demo_load_env() { + local envfile="$1" + if [[ -f "$envfile" ]]; then + # shellcheck disable=SC1090 + set -a; source "$envfile"; set +a + fi +} diff --git a/tools/demo/marks.py b/tools/demo/marks.py new file mode 100755 index 000000000..4eeff226c --- /dev/null +++ b/tools/demo/marks.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +marks.py -- turn a scenario's timing marks into cuts, screenshots, manifests. + +The scenarios in integration_test/demo/ report a list of marks through the +Flutter driver. Each mark carries a scenario-relative timestamp, a label, and +optionally a caption. This script maps those onto the raw screen recording. + +Three subcommands: + + cut Trim the install/launch lead-in and EXCISE every blindfold span + (frames where a mnemonic was on screen), producing a clip that is + safe to publish. + + shots Pull store screenshots at native device resolution, one per + captioned mark. Frames are taken from the MIDDLE of each hold + rather than at its leading edge, which makes alignment robust + against the ~0.5s of encoder start-up jitter. + + manifest Assemble the per-scenario clips into a reel.py manifest. + +TIME BASE + recstart device-clock epoch (ms) read just before + `adb shell screenrecord` was launched + started_at_epoch_ms device-clock epoch (ms) at scenario start + + video_time(mark) = (started_at_epoch_ms - recstart)/1000 + mark.at + - LEAD_CORRECTION + + Both epochs come from the DEVICE clock, so host/guest skew cannot break + alignment. LEAD_CORRECTION absorbs the small delay between launching + screenrecord and the encoder producing its first frame. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# Seconds between `screenrecord` being launched and it emitting frame 0. +# Measured empirically on the x86_64 emulator; override with --lead-correction. +LEAD_CORRECTION = 0.45 + +# Safety margin applied around every blindfold span. A span boundary lands on a +# mark, and the frames immediately either side can still show the tail of a +# seed screen (or its dismiss animation). Used by BOTH the cut and the +# screenshot paths -- they must agree, or a screenshot can land in a region the +# video cut treated as unsafe. +BLINDFOLD_PAD = 0.6 + + +def run(cmd: list[str]) -> None: + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(r.stderr[-3000:]) + raise SystemExit(f"command failed: {' '.join(str(c) for c in cmd[:8])} ...") + + +def probe_duration(path: Path) -> float: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nk=1:nw=1", str(path)], + capture_output=True, text=True, + ) + try: + return float(r.stdout.strip()) + except ValueError: + raise SystemExit(f"cannot probe {path}") + + +def load_demo(marks_path: Path | None) -> dict | None: + """Read the `demo` block out of the driver's response JSON.""" + if not marks_path or not marks_path.exists(): + return None + try: + data = json.loads(marks_path.read_text()) + except json.JSONDecodeError: + return None + if not data: + return None + demo = data.get("demo") + # integrationDriver nests per-test results; dig one level if needed. + if demo is None: + for v in data.values(): + if isinstance(v, dict) and "demo" in v: + demo = v["demo"] + break + if isinstance(v, str): + try: + inner = json.loads(v) + if isinstance(inner, dict) and "demo" in inner: + demo = inner["demo"] + break + except json.JSONDecodeError: + continue + return demo + + +def video_offset(demo: dict, recstart_path: Path | None, lead: float) -> float: + if not recstart_path or not recstart_path.exists(): + return 0.0 + try: + recstart = float(recstart_path.read_text().strip()) + except ValueError: + return 0.0 + started = float(demo.get("started_at_epoch_ms") or 0) + if not started: + return 0.0 + return max((started - recstart) / 1000.0 - lead, 0.0) + + +# --------------------------------------------------------------------------- +# cut +# --------------------------------------------------------------------------- +def safe_spans(demo: dict, offset: float, duration: float) -> list[tuple[float, float]]: + """Video-time spans that contain no mnemonic frames. + + Everything before the scenario actually starts is also dropped -- that is + the install/launch lead-in, which is not part of the story. + """ + blind = [ + (offset + float(s["start"]), offset + float(s["end"])) + for s in (demo.get("blindfold_spans") or []) + ] + blind = [(max(a - BLINDFOLD_PAD, 0.0), min(b + BLINDFOLD_PAD, duration)) + for a, b in blind] + blind.sort() + + spans: list[tuple[float, float]] = [] + cursor = offset + for a, b in blind: + if a > cursor: + spans.append((cursor, min(a, duration))) + cursor = max(cursor, b) + if cursor < duration: + spans.append((cursor, duration)) + # Drop slivers that are not worth a cut. + return [(a, b) for a, b in spans if b - a > 0.8] + + +def cmd_cut(args: argparse.Namespace) -> None: + raw = Path(args.raw) + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + duration = probe_duration(raw) + demo = load_demo(Path(args.marks) if args.marks else None) + + if demo is None: + print("WARNING: no marks available; copying raw recording unchanged. " + "This clip has NOT been checked for seed frames — do not publish " + "it without watching it.", file=sys.stderr) + run(["ffmpeg", "-y", "-v", "error", "-i", str(raw), + "-c", "copy", "-movflags", "+faststart", str(out)]) + manifest = {"scenario": raw.stem, "verified_seed_free": False, + "captions": [], "duration": duration} + Path(args.manifest).write_text(json.dumps(manifest, indent=2)) + return + + offset = video_offset(demo, Path(args.recstart) if args.recstart else None, + args.lead_correction) + spans = safe_spans(demo, offset, duration) + if not spans: + raise SystemExit( + f"every frame of {raw} falls inside a blindfold span — nothing " + f"safe to publish. Check the scenario." + ) + + blind_count = len(demo.get("blindfold_spans") or []) + tmp = out.parent / f".{out.stem}-parts" + tmp.mkdir(exist_ok=True) + parts = [] + for i, (a, b) in enumerate(spans): + p = tmp / f"part{i:02d}.mp4" + run(["ffmpeg", "-y", "-v", "error", "-ss", f"{a:.3f}", "-to", f"{b:.3f}", + "-i", str(raw), "-an", "-c:v", "libx264", "-preset", "veryfast", + "-crf", "18", "-pix_fmt", "yuv420p", str(p)]) + parts.append(p) + + if len(parts) == 1: + run(["ffmpeg", "-y", "-v", "error", "-i", str(parts[0]), + "-c", "copy", "-movflags", "+faststart", str(out)]) + else: + listing = tmp / "concat.txt" + listing.write_text("".join(f"file '{p.resolve()}'\n" for p in parts)) + run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", + "-i", str(listing), "-c", "copy", "-movflags", "+faststart", + str(out)]) + for p in parts: + p.unlink(missing_ok=True) + (tmp / "concat.txt").unlink(missing_ok=True) + tmp.rmdir() + + # Captions, rebased onto the CUT timeline. + captions = [] + removed_before = 0.0 + span_idx = 0 + for m in demo.get("marks", []): + if not m.get("caption"): + continue + vt = offset + float(m["at"]) + # find which safe span this mark falls in, and how much was removed + acc = 0.0 + placed = None + for a, b in spans: + if a <= vt <= b: + placed = acc + (vt - a) + break + acc += b - a + if placed is not None: + captions.append({"at": round(placed, 3), "text": m["caption"], + "label": m["label"]}) + + manifest = { + "scenario": demo.get("scenario", raw.stem), + "verified_seed_free": True, + "blindfold_spans_excised": blind_count, + "duration": round(sum(b - a for a, b in spans), 3), + "captions": captions, + } + Path(args.manifest).write_text(json.dumps(manifest, indent=2)) + print(f"cut {out.name}: {manifest['duration']:.1f}s, " + f"{blind_count} blindfold span(s) excised, " + f"{len(captions)} caption(s)") + + +# --------------------------------------------------------------------------- +# shots +# --------------------------------------------------------------------------- +def cmd_shots(args: argparse.Namespace) -> None: + raw = Path(args.raw) + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + demo = load_demo(Path(args.marks) if args.marks else None) + if demo is None: + raise SystemExit(f"no marks for {raw}; cannot place screenshots") + + duration = probe_duration(raw) + offset = video_offset(demo, Path(args.recstart) if args.recstart else None, + args.lead_correction) + # Same padding the video cut uses -- otherwise a screenshot could be taken + # from a region `cut` considered unsafe enough to excise. + blind = [(offset + float(s["start"]) - BLINDFOLD_PAD, + offset + float(s["end"]) + BLINDFOLD_PAD) + for s in (demo.get("blindfold_spans") or [])] + + marks = demo.get("marks", []) + scenario = demo.get("scenario", raw.stem) + made = 0 + for i, m in enumerate(marks): + if not m.get("caption"): + continue + start_vt = offset + float(m["at"]) + # Land in the middle of the hold, not on its edge: the next mark tells + # us when this screen stops being on camera. + next_vt = duration + for later in marks[i + 1:]: + nv = offset + float(later["at"]) + if nv > start_vt: + next_vt = nv + break + at = start_vt + min((next_vt - start_vt) / 2.0, 2.5) + at = min(at, duration - 0.2) + + if any(a <= at <= b for a, b in blind): + print(f" skip {m['label']}: inside a blindfold span", file=sys.stderr) + continue + + name = f"{scenario}-{made + 1:02d}-{m['label']}.png" + run(["ffmpeg", "-y", "-v", "error", "-ss", f"{at:.3f}", "-i", str(raw), + "-frames:v", "1", str(outdir / name)]) + made += 1 + print(f" {name} @{at:.2f}s") + + print(f"{made} screenshot(s) -> {outdir}") + if made: + sample = next(outdir.glob(f"{scenario}-*.png"), None) + if sample: + dims = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", + str(sample)], capture_output=True, text=True).stdout.strip() + print(f"dimensions: {dims}") + w, h = (int(x) for x in dims.split("x")) + if h / w > 2.0: + print(f"WARNING: aspect {h/w:.2f}:1 exceeds Google Play's 2:1 " + f"maximum for phone screenshots.", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# manifest +# --------------------------------------------------------------------------- +def cmd_manifest(args: argparse.Namespace) -> None: + cutdir = Path(args.cutdir) + scenarios = args.scenarios.split() + clips = [] + unsafe = [] + for s in scenarios: + clip = cutdir / f"{s}.mp4" + meta = cutdir / f"{s}.manifest.json" + if not clip.exists(): + continue + info = json.loads(meta.read_text()) if meta.exists() else {} + if not info.get("verified_seed_free", False): + unsafe.append(s) + dur = probe_duration(clip) + caption = "" + caps = info.get("captions") or [] + if caps: + caption = caps[0]["text"] + # Apple previews get no captions at all. + if args.profile == "apple-preview": + caption = "" + clips.append({ + "src": str(clip), + "caption": caption, + "seconds": round(min(dur, args.max_clip), 2), + "start": 0, + }) + + if not clips: + raise SystemExit(f"no cut clips found in {cutdir}. Record something first.") + + if unsafe: + raise SystemExit( + "REFUSING to build a publishable master.\n" + f"These clips were never verified seed-free: {', '.join(unsafe)}\n" + "They were cut without driver marks, so blindfold spans could not " + "be excised. Re-record them, or watch each one and cut by hand." + ) + + manifest = {"transition": "cross_dissolve", "clips": clips} + if args.music: + manifest["music"] = args.music + Path(args.out).write_text(json.dumps(manifest, indent=2)) + total = sum(c["seconds"] for c in clips) + print(f"manifest: {len(clips)} clip(s), ~{total:.0f}s -> {args.out}") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("cut") + c.add_argument("--raw", required=True) + c.add_argument("--marks") + c.add_argument("--recstart") + c.add_argument("--out", required=True) + c.add_argument("--manifest", required=True) + c.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION) + c.set_defaults(func=cmd_cut) + + s = sub.add_parser("shots") + s.add_argument("--raw", required=True) + s.add_argument("--marks") + s.add_argument("--recstart") + s.add_argument("--outdir", required=True) + s.add_argument("--lead-correction", type=float, default=LEAD_CORRECTION) + s.set_defaults(func=cmd_shots) + + m = sub.add_parser("manifest") + m.add_argument("--cutdir", required=True) + m.add_argument("--profile", default="social") + m.add_argument("--out", required=True) + m.add_argument("--scenarios", required=True) + m.add_argument("--music", default=None) + m.add_argument("--max-clip", type=float, default=8.0) + m.set_defaults(func=cmd_manifest) + + args = ap.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/demo/packs/hash/pack.json b/tools/demo/packs/hash/pack.json new file mode 100644 index 000000000..e9f4a1c98 --- /dev/null +++ b/tools/demo/packs/hash/pack.json @@ -0,0 +1,37 @@ +{ + "name": "hash", + "description": "Hash Bags — an open-source, non-custodial multi-currency wallet by Such Software LLC. Wownero-first, privacy-respecting, no KYC. Deep-forest green on near-black, clean geometric sans, the '#' mark. Calm and credible, not hype.", + "palette": { + "bg": "#0a1a12", + "bg_mid": "#0f2b1c", + "bg_hi": "#123f28", + "surface": "#123f28", + "ink": "#e8efe9", + "muted": "#7fae97", + "accent": "#2EA56A", + "accent_hi": "#8fd6b0", + "primary": "#1A5C38" + }, + "type": { + "families": { "display": "Inter Display", "body": "Inter" }, + "scale": { "title": 96, "subhead": 52, "body": 34, "caption": 48 }, + "font_files": { + "display": "/usr/share/fonts/opentype/inter/InterDisplay-Bold.otf", + "body": "/usr/share/fonts/opentype/inter/Inter-SemiBold.otf" + }, + "font_fallbacks": [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf" + ] + }, + "motion": { + "transition_default": "cross_dissolve", + "transition_social": "blur_punch" + }, + "copy": { + "title": "Hash Bags", + "subtitle": "You hold the keys.", + "cta": "hash.boats", + "cta_sub": "open-source · non-custodial · no KYC" + } +} diff --git a/tools/demo/reel.py b/tools/demo/reel.py new file mode 100755 index 000000000..13cf03069 --- /dev/null +++ b/tools/demo/reel.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +""" +reel.py -- assemble Hash Bags marketing video from recorded wallet captures. + +Takes the raw screen recordings produced by capture.sh (one MP4 per scenario) +plus a manifest describing captions and pacing, and renders a finished master. + +Profiles decide how much production sits on top of the raw capture: + + social 1080x1920 vertical. Title card, per-clip burned captions on a + blurred fill, xfade transitions, CTA end card, optional music. + This is the TikTok / Reels / Shorts deliverable. + + apple-preview App Store App Preview. DELIBERATELY BARE: no burned captions, + no title/CTA cards, no tap overlay, no music bed. Apple wants + the app itself on screen, and the recording is used as shot. + Trimmed to Apple's 15-30s window and left at device pixels. + + play-listing Google Play listing video. Same production as `social` but + sized 1080x1920 and duration-capped at 30s. + + raw Passthrough + QA only. Useful for eyeballing a capture. + +The genuinely reusable pieces of ~/src/such-graphics (video_transitions for the +xfade preset table, video_qa for the "did this decode to black" gate) are +imported when that repo is checked out, and cleanly stubbed when it is not -- +hash-wallet is public and must build without it. + +Usage: + python3 tools/demo/reel.py --manifest reel.json --profile social -o out.mp4 + python3 tools/demo/reel.py --manifest reel.json --profile apple-preview \ + -o preview.mp4 + +Manifest shape: + { + "title": "Hash Bags", # optional, defaults from pack.json + "subtitle": "You hold the keys.", + "cta": "hash.boats", + "transition": "cross_dissolve", # optional + "music": "/path/to/bed.mp3", # optional; ignored by apple-preview + "clips": [ + {"src": "captures/receive.mp4", "caption": "Receive in a tap", + "seconds": 6, "start": 1.5} + ] + } +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_PACK = HERE / "packs" / "hash" / "pack.json" + +# --------------------------------------------------------------------------- +# such-graphics interop (optional) +# --------------------------------------------------------------------------- +SUCH_GRAPHICS_HOME = Path( + os.environ.get("SUCH_GRAPHICS_HOME", Path.home() / "src" / "such-graphics") +) + +_FALLBACK_TRANSITIONS = { + "cross_dissolve": ("fade", 0.52), + "blur_punch": ("hblur", 0.42), + "black_dip": ("fadeblack", 0.34), + "slide_up": ("slideup", 0.40), + "zoom_punch": ("zoomin", 0.45), + "whip_left": ("smoothleft", 0.38), +} + + +@dataclass(frozen=True) +class Transition: + ffmpeg_name: str + duration: float + + +def load_transition(name: str) -> Transition: + """Prefer such-graphics' tuned preset table; fall back to a local subset.""" + if SUCH_GRAPHICS_HOME.is_dir(): + sys.path.insert(0, str(SUCH_GRAPHICS_HOME)) + try: + from such_graphics.video_transitions import ( # noqa: E402 + get_video_transition, + ) + + t = get_video_transition(name) + return Transition(t.ffmpeg_name, t.duration) + except Exception: + pass # fall through to the local table + ff, dur = _FALLBACK_TRANSITIONS.get(name, _FALLBACK_TRANSITIONS["cross_dissolve"]) + return Transition(ff, dur) + + +def run_video_qa(path: Path) -> str: + """Decode-level QA. ffmpeg can emit a valid MP4 that is entirely black -- + a real hazard with headless emulator capture, so this gate matters.""" + if SUCH_GRAPHICS_HOME.is_dir(): + sys.path.insert(0, str(SUCH_GRAPHICS_HOME)) + try: + from such_graphics.video_qa import verify_video # noqa: E402 + + verify_video(path) + return "such_graphics.video_qa: PASS" + except ImportError: + pass + except Exception as exc: # QA genuinely failed -- surface it + raise SystemExit(f"VIDEO QA FAILED for {path}: {exc}") + return _local_black_check(path) + + +def _local_black_check(path: Path) -> str: + """Minimal standalone equivalent: sample frames, fail if all are black.""" + dur = probe_duration(path) + samples = [t for t in (0.5, dur * 0.25, dur * 0.5, dur * 0.75) if t < dur] + brightest = 0.0 + for at in samples: + r = subprocess.run( + ["ffmpeg", "-v", "error", "-ss", f"{at}", "-i", str(path), + "-frames:v", "1", "-vf", "scale=64:64,format=gray", + "-f", "rawvideo", "-"], + capture_output=True, + ) + if r.stdout: + brightest = max(brightest, sum(r.stdout) / len(r.stdout)) + if brightest < 4.0: + raise SystemExit( + f"VIDEO QA FAILED for {path}: decodes to black " + f"(peak mean luma {brightest:.2f}). The capture likely raced the " + f"app launch, or the emulator GPU produced no frames." + ) + return f"local black-check: PASS (peak mean luma {brightest:.1f})" + + +# --------------------------------------------------------------------------- +# ffmpeg helpers +# --------------------------------------------------------------------------- +W, H, FPS = 1080, 1920, 30 + + +def run(cmd: list[str]) -> None: + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(r.stderr[-4000:]) + raise SystemExit(f"ffmpeg failed: {' '.join(str(c) for c in cmd[:8])} ...") + + +def probe_duration(path: str | Path) -> float: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nk=1:nw=1", str(path)], + capture_output=True, text=True, + ) + try: + return float(r.stdout.strip()) + except ValueError: + raise SystemExit(f"cannot probe duration of {path} -- is it a valid video?") + + +def esc(text: str) -> str: + """Escape text for the ffmpeg drawtext filter.""" + return ( + text.replace("\\", "\\\\") + .replace(":", "\\:") + .replace("'", "’") + .replace("%", "\\%") + ) + + +class Pack: + def __init__(self, path: Path): + self.data = json.loads(Path(path).read_text()) + self.palette = self.data["palette"] + self.copy = self.data.get("copy", {}) + self.font = self._resolve_font("display") + self.body_font = self._resolve_font("body") + + def _resolve_font(self, role: str) -> str: + t = self.data.get("type", {}) + cand = [t.get("font_files", {}).get(role, "")] + cand += t.get("font_fallbacks", []) + for c in cand: + if c and Path(c).exists(): + return c + raise SystemExit( + "no usable font found; install Inter or DejaVu, or edit " + f"{DEFAULT_PACK} type.font_fallbacks" + ) + + def hexc(self, key: str) -> str: + """pack.json stores #rrggbb; ffmpeg wants 0xrrggbb.""" + return "0x" + self.palette[key].lstrip("#") + + +def drawtext(pack: Pack, text: str, y: str, size: int, color: str, + box: bool = True, font: str | None = None) -> str: + parts = [ + f"fontfile={font or pack.font}", + f"text='{esc(text)}'", + "x=(w-text_w)/2", + f"y={y}", + f"fontsize={size}", + f"fontcolor={color}", + ] + if box: + parts += ["box=1", "boxcolor=black@0.55", "boxborderw=28"] + return "drawtext=" + ":".join(parts) + + +# --------------------------------------------------------------------------- +# segment builders +# --------------------------------------------------------------------------- +def build_card(pack: Pack, lines: list[str], seconds: float, out: Path) -> None: + """Brand card: centered lines, first line accent-coloured and larger.""" + draws = [] + n = len(lines) + for i, line in enumerate(lines): + big = i == 0 + color = pack.hexc("accent") if big else pack.hexc("ink") + size = pack.data["type"]["scale"]["title"] if big else pack.data["type"]["scale"]["subhead"] + yoff = int((i - (n - 1) / 2) * 150) + draws.append( + drawtext(pack, line, y=f"(h-text_h)/2+{yoff}", size=size, + color=color, box=False, + font=pack.font if big else pack.body_font) + ) + # Flat brand field with a single lift pass. Deliberately plain: the card + # exists to frame the wordmark, not to compete with the app footage. + bg = ( + f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}[base];" + f"[base]drawbox=x=0:y=0:w={W}:h={H}:color={pack.hexc('bg_hi')}@0.35:t=fill[grad];" + f"[grad]" + ",".join(draws) + ",format=yuv420p[v]" + ) + run([ + "ffmpeg", "-y", "-f", "lavfi", + "-i", f"color=c={pack.hexc('bg')}:s={W}x{H}:d={seconds}:r={FPS}", + "-filter_complex", bg, "-map", "[v]", "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", str(out), + ]) + + +def build_clip(pack: Pack, src: str, caption: str, seconds: float, out: Path, + start: float = 0.0, captioned: bool = True) -> None: + """Frame one capture on a blurred fill of itself, optionally caption it. + + `start` seeks into the clip before taking `seconds`. Use it to skip past + the seed-entry frames of a restore recording so a mnemonic never lands on + camera even if the scenario paused there. + """ + chain = ( + f"[0:v]scale={W}:{H}:force_original_aspect_ratio=increase," + f"crop={W}:{H},boxblur=40:2,eq=brightness=-0.32:saturation=0.7[bg];" + f"[0:v]scale=-2:1480,pad=iw+8:ih+8:4:4:{pack.hexc('accent')}@0.9[fg];" + f"[bg][fg]overlay=(W-w)/2:(H-h)/2-60[cmp];" + ) + tail = ( + f"fade=t=in:st=0:d=0.4,fade=t=out:st={max(seconds - 0.4, 0.1):.2f}:d=0.4," + f"setsar=1,fps={FPS},format=yuv420p[v]" + ) + if captioned and caption: + cap = drawtext(pack, caption, y="h-260", + size=pack.data["type"]["scale"]["caption"], + color=pack.hexc("accent_hi")) + chain += f"[cmp]{cap},{tail}" + else: + chain += f"[cmp]{tail}" + + run([ + "ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src, + "-filter_complex", chain, "-map", "[v]", "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-t", f"{seconds}", str(out), + ]) + + +def build_bare_clip(src: str, seconds: float, out: Path, start: float = 0.0) -> None: + """Apple App Preview path: re-encode only. No framing, no caption, no + overlay -- the recording is the deliverable. Kept at source geometry so + the device pixels Apple expects survive untouched.""" + run([ + "ffmpeg", "-y", "-ss", f"{start}", "-t", f"{seconds}", "-i", src, + "-an", "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-r", str(FPS), + "-movflags", "+faststart", str(out), + ]) + + +def xfade_concat(segments: list[Path], transition: str, out: Path) -> None: + """Chain N segments with a single xfade preset. Video only.""" + t = load_transition(transition) + inputs: list[str] = [] + for s in segments: + inputs += ["-i", str(s)] + durs = [probe_duration(s) for s in segments] + filt = [] + prev = "[0:v]" + running = durs[0] + for i in range(1, len(segments)): + off = max(running - t.duration, 0.1) + label = f"[x{i}]" + filt.append( + f"{prev}[{i}:v]xfade=transition={t.ffmpeg_name}:" + f"duration={t.duration}:offset={off:.3f}{label}" + ) + prev = label + running = running + durs[i] - t.duration + run([ + "ffmpeg", "-y", *inputs, + "-filter_complex", ";".join(filt), "-map", prev, + "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-colorspace", "bt709", "-color_primaries", "bt709", + "-movflags", "+faststart", str(out), + ]) + + +def concat_simple(segments: list[Path], out: Path, tmp: Path) -> None: + """Straight concat, no transitions (apple-preview keeps cuts honest).""" + listing = tmp / "concat.txt" + listing.write_text("".join(f"file '{s}'\n" for s in segments)) + run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(listing), + "-c:v", "libx264", "-preset", "slow", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out), + ]) + + +def mix_music(video: Path, music: str, out: Path, seed: int | None = None) -> None: + """Bake a random-offset slice of the bed under the master, with fades. + + Random offset means repeat uploads of the same variant do not feel + identical -- lifted from the medusa marketing-video builder. + """ + dur = probe_duration(video) + bed_len = probe_duration(music) + rng = random.Random(seed) + max_off = max(bed_len - dur - 1.0, 0.0) + off = rng.uniform(0, max_off) if max_off > 0 else 0.0 + run([ + "ffmpeg", "-y", "-i", str(video), "-ss", f"{off:.2f}", "-i", music, + "-filter_complex", + f"[1:a]atrim=0:{dur:.2f},afade=t=in:st=0:d=0.5," + f"afade=t=out:st={max(dur - 1.0, 0.1):.2f}:d=1.0,volume=0.35[a]", + "-map", "0:v", "-map", "[a]", + "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", + "-shortest", "-movflags", "+faststart", str(out), + ]) + + +# --------------------------------------------------------------------------- +# profiles +# --------------------------------------------------------------------------- +APPLE_MIN, APPLE_MAX = 15.0, 30.0 +PLAY_MAX = 30.0 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--manifest", required=True) + ap.add_argument("-o", "--out", required=True) + ap.add_argument("--profile", default="social", + choices=["social", "apple-preview", "play-listing", "raw"]) + ap.add_argument("--pack", default=str(DEFAULT_PACK)) + ap.add_argument("--transition", default=None) + ap.add_argument("--music", default=None, help="override manifest music bed") + ap.add_argument("--seed", type=int, default=None, + help="deterministic music offset (for reproducible renders)") + ap.add_argument("--keep-work", action="store_true") + args = ap.parse_args() + + if not shutil.which("ffmpeg"): + raise SystemExit("ffmpeg not found on PATH") + + pack = Pack(Path(args.pack)) + m = json.loads(Path(args.manifest).read_text()) + clips = m.get("clips", []) + if not clips: + raise SystemExit(f"{args.manifest} has no clips") + for c in clips: + if not Path(c["src"]).exists(): + raise SystemExit(f"clip source missing: {c['src']}") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + tmp = Path(tempfile.mkdtemp(prefix="hashbags-reel-")) + segs: list[Path] = [] + + apple = args.profile == "apple-preview" + branded = args.profile in ("social", "play-listing") + + try: + if branded: + title = m.get("title", pack.copy.get("title", "Hash Bags")) + subtitle = m.get("subtitle", pack.copy.get("subtitle", "")) + seg = tmp / "00-title.mp4" + build_card(pack, [title, subtitle], 2.5, seg) + segs.append(seg) + + for i, clip in enumerate(clips): + seg = tmp / f"{i + 1:02d}-clip.mp4" + seconds = float(clip.get("seconds", 5)) + start = float(clip.get("start", 0)) + if apple or args.profile == "raw": + build_bare_clip(clip["src"], seconds, seg, start=start) + else: + build_clip(pack, clip["src"], clip.get("caption", ""), + seconds, seg, start=start, captioned=True) + segs.append(seg) + + if branded: + cta = m.get("cta", pack.copy.get("cta", "hash.boats")) + cta_sub = m.get("cta_sub", pack.copy.get("cta_sub", "")) + seg = tmp / "99-cta.mp4" + build_card(pack, [cta, cta_sub], 2.5, seg) + segs.append(seg) + + silent = tmp / "master-silent.mp4" + if apple or args.profile == "raw": + # Honest cuts, no transition sugar, geometry preserved. + concat_simple(segs, silent, tmp) + else: + transition = (args.transition or m.get("transition") + or pack.data["motion"]["transition_default"]) + xfade_concat(segs, transition, silent) + + dur = probe_duration(silent) + + # ---- profile duration gates ------------------------------------ + if apple: + if dur > APPLE_MAX: + raise SystemExit( + f"apple-preview is {dur:.1f}s; Apple accepts {APPLE_MIN:.0f}-" + f"{APPLE_MAX:.0f}s. Trim clip `seconds` in {args.manifest}." + ) + if dur < APPLE_MIN: + raise SystemExit( + f"apple-preview is {dur:.1f}s; Apple requires at least " + f"{APPLE_MIN:.0f}s. Lengthen clip `seconds` in {args.manifest}." + ) + if args.profile == "play-listing" and dur > PLAY_MAX: + print(f"WARNING: {dur:.1f}s exceeds the {PLAY_MAX:.0f}s Play " + f"listing-video guidance", file=sys.stderr) + + # ---- music ------------------------------------------------------ + music = args.music or m.get("music") + if music and apple: + print("NOTE: apple-preview ignores the music bed on purpose " + "(App Preview should carry app audio or silence)", + file=sys.stderr) + music = None + if music and Path(music).exists(): + mix_music(silent, music, out, seed=args.seed) + else: + shutil.copy(silent, out) + + qa = run_video_qa(out) + print(f"wrote {out}") + print(f" profile {args.profile}") + print(f" duration {probe_duration(out):.1f}s") + print(f" qa {qa}") + if apple: + print(" overlays NONE (no captions/cards/tap effect) — per App " + "Preview deliverable rules") + finally: + if args.keep_work: + print(f" work dir {tmp}") + else: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main()