diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b16277de9..dc5c94703 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,10 +26,22 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> + + + + + + + + diff --git a/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt b/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt index a81c44524..0b45161c6 100644 --- a/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt +++ b/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt @@ -11,6 +11,9 @@ import io.flutter.plugin.common.MethodChannel // FlutterFragmentActivity: BiometricPrompt requires a FragmentActivity host. class MainActivity : FlutterFragmentActivity() { private lateinit var deviceOwnerAuthHandler: DeviceOwnerAuthHandler + private var paymentUriChannel: MethodChannel? = null + private val pendingPaymentUris = mutableListOf() + private var paymentUriDartReady = false override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -64,6 +67,29 @@ class MainActivity : FlutterFragmentActivity() { else -> result.notImplemented() } } + + paymentUriChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + PAYMENT_URI_CHANNEL + ).apply { + setMethodCallHandler { call, result -> + when (call.method) { + "takePendingUris" -> { + val uris = pendingPaymentUris.toList() + pendingPaymentUris.clear() + result.success(uris) + } + "ready" -> { + paymentUriDartReady = true + flushPendingPaymentUris() + result.success(null) + } + else -> result.notImplemented() + } + } + } + // A zcash: link that cold-starts Vizor arrives as the launch intent. + capturePaymentUri(intent) } /** REJECT is the platform's error haptic; older APIs report @@ -111,9 +137,35 @@ class MainActivity : FlutterFragmentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // singleTop launchMode: a zcash: link tapped while Vizor is already + // running is delivered here instead of through a fresh launch intent. + setIntent(intent) + capturePaymentUri(intent) + } + + private fun capturePaymentUri(intent: Intent?) { + if (intent == null || intent.action != Intent.ACTION_VIEW) return + if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return + val data = intent.data ?: return + if (!"zcash".equals(data.scheme, ignoreCase = true)) return + pendingPaymentUris.add(intent.dataString ?: data.toString()) + flushPendingPaymentUris() + } + + private fun flushPendingPaymentUris() { + if (!paymentUriDartReady || pendingPaymentUris.isEmpty()) return + val channel = paymentUriChannel ?: return + val uris = pendingPaymentUris.toList() + pendingPaymentUris.clear() + channel.invokeMethod("onUris", uris) + } + companion object { private const val CAMERA_PERMISSION_CHANNEL = "com.zcash.wallet/camera_permission" private const val HAPTICS_CHANNEL = "com.zcash.wallet/haptics" private const val PRIVACY_SHIELD_CHANNEL = "com.zcash.wallet/privacy_shield" + private const val PAYMENT_URI_CHANNEL = "com.zcash.wallet/payment_uri" } } diff --git a/dev/open_payment_uri.sh b/dev/open_payment_uri.sh new file mode 100755 index 000000000..5af9c79f3 --- /dev/null +++ b/dev/open_payment_uri.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +uri='zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez' +uri+='?amount=0.12345678' +uri+='&memo=Q1AtQzZDREI3NzU' +uri+='&message=Thank%20you%20for%20your%20purchase' + +case "$(uname -s)" in + Darwin) + open "$uri" + ;; + Linux) + xdg-open "$uri" + ;; + MINGW*|MSYS*|CYGWIN*) + cmd.exe /c start "" "$uri" + ;; + *) + printf 'Unsupported platform for opening payment URI\n' >&2 + exit 1 + ;; +esac diff --git a/dev/payment_uri_demo.html b/dev/payment_uri_demo.html new file mode 100644 index 000000000..6385c0d06 --- /dev/null +++ b/dev/payment_uri_demo.html @@ -0,0 +1,59 @@ + + + + + + Vizor Payment URI Demo + + + +
+ + Open in Vizor + + + + zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=0.12345678&memo=Q1AtQzZDREI3NzU&message=Thank%20you%20for%20your%20purchase + +
+ + diff --git a/integration_test/payment_uri_prefill_test.dart b/integration_test/payment_uri_prefill_test.dart new file mode 100644 index 000000000..478bf7ee2 --- /dev/null +++ b/integration_test/payment_uri_prefill_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:zcash_wallet/app.dart'; +import 'package:zcash_wallet/src/app_bootstrap.dart'; +import 'package:zcash_wallet/src/core/config/rpc_endpoint_config.dart'; +import 'package:zcash_wallet/src/core/theme/app_theme.dart'; +import 'package:zcash_wallet/src/features/send/models/send_prefill_args.dart'; +import 'package:zcash_wallet/src/features/send/screens/send_screen.dart'; +import 'package:zcash_wallet/src/providers/account_models.dart'; +import 'package:zcash_wallet/src/providers/sync_provider.dart'; + +const _accountUuid = '550e8400-e29b-41d4-a716-446655440000'; +const _address = + 'ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez'; +const _prefill = SendPrefillArgs( + id: 'payment-uri-e2e', + source: 'zcash-uri', + address: _address, + amountText: '0.12345678', + memoText: 'CP-C6CDB775', +); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await initializeZcashWalletRuntime(); + }); + + testWidgets('payment URI prefill survives send route refresh', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1512, 982)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + final router = GoRouter( + initialLocation: '/send', + initialExtra: _prefill, + routes: [ + GoRoute( + path: '/send', + builder: (_, state) { + final extra = state.extra; + return SendScreen(prefill: extra is SendPrefillArgs ? extra : null); + }, + ), + GoRoute(path: '/home', builder: (_, _) => const Text('home route')), + ], + ); + + await tester.pumpWidget(_harness(router)); + + await _waitForFieldText( + tester, + const ValueKey('send_address_field'), + _address, + description: 'payment URI address prefill', + ); + expect( + _fieldText(tester, const ValueKey('send_amount_field')), + '0.12345678', + ); + expect( + _fieldText(tester, const ValueKey('send_memo_field')), + 'CP-C6CDB775', + ); + + router.go('/send'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(_fieldText(tester, const ValueKey('send_address_field')), _address); + expect( + _fieldText(tester, const ValueKey('send_amount_field')), + '0.12345678', + ); + expect( + _fieldText(tester, const ValueKey('send_memo_field')), + 'CP-C6CDB775', + ); + }); +} + +Widget _harness(GoRouter router) { + return ProviderScope( + overrides: [ + appBootstrapProvider.overrideWithValue(_bootstrap), + syncProvider.overrideWith(() => _FakeSyncNotifier(_syncState)), + ], + child: MaterialApp.router( + routerConfig: router, + builder: (_, child) => AppTheme(data: AppThemeData.light, child: child!), + ), + ); +} + +Future _waitForFieldText( + WidgetTester tester, + Key key, + String expected, { + required String description, + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (tester.any(find.byKey(key)) && _fieldText(tester, key) == expected) { + return; + } + await tester.pump(const Duration(milliseconds: 100)); + } + fail('Timed out waiting for $description.'); +} + +String _fieldText(WidgetTester tester, Key key) { + final editable = find.descendant( + of: find.byKey(key), + matching: find.byType(EditableText), + ); + return tester.widget(editable).controller.text; +} + +final _bootstrap = AppBootstrapState( + initialLocation: '/send', + initialAccountState: const AccountState( + accounts: [AccountInfo(uuid: _accountUuid, name: 'Account 1', order: 0)], + activeAccountUuid: _accountUuid, + activeAddress: 'u1paymenturiprefillwalletaddress', + ), + initialSyncSnapshot: AppSyncSnapshot.empty, + network: 'main', + rpcEndpointConfig: defaultRpcEndpointConfig('main'), + themeMode: ThemeMode.system, + privacyModeEnabled: false, + isPasswordConfigured: true, + isUnlocked: true, + passwordRotationRecoveryFailed: false, +); + +final _syncState = SyncState( + accountUuid: _accountUuid, + hasAccountScopedData: true, + scannedHeight: 1, + chainTipHeight: 1, + spendableBalance: BigInt.zero, + totalBalance: BigInt.zero, +); + +class _FakeSyncNotifier extends SyncNotifier { + _FakeSyncNotifier(this.initialState); + + final SyncState initialState; + + @override + Future build() async => initialState; +} diff --git a/integration_test/regtest_payment_uri_locked_send_test.dart b/integration_test/regtest_payment_uri_locked_send_test.dart new file mode 100644 index 000000000..8b5fafc66 --- /dev/null +++ b/integration_test/regtest_payment_uri_locked_send_test.dart @@ -0,0 +1,700 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:zcash_wallet/app.dart'; +import 'package:zcash_wallet/src/core/config/network_config.dart'; +import 'package:zcash_wallet/src/core/storage/app_secure_store.dart'; +import 'package:zcash_wallet/src/core/storage/wallet_paths.dart'; +import 'package:zcash_wallet/src/core/widgets/app_button.dart'; +import 'package:zcash_wallet/src/providers/account_models.dart'; +import 'package:zcash_wallet/src/rust/api/sync.dart' as rust_sync; +import 'package:zcash_wallet/src/rust/api/wallet.dart' as rust_wallet; + +// End-to-end regtest coverage for the ZIP-321 payment-URI LOCKED path — the +// regression guard for delivering a parked prefill to /send after unlock +// instead of the default /home. Imports a faucet-funded wallet, signs out to +// lock it, injects a `zcash:
?amount=...` link over the +// `com.zcash.wallet/payment_uri` MethodChannel while locked, then unlocks and +// asserts the wallet lands on the prefilled send screen (address + amount) and +// that the resulting shielded send mines on the live regtest network. + +const _network = String.fromEnvironment( + 'ZCASH_E2E_NETWORK', + defaultValue: 'regtest', +); +const _lightwalletdUrl = String.fromEnvironment( + 'ZCASH_E2E_LIGHTWALLETD_URL', + defaultValue: 'http://127.0.0.1:9067', +); +const _zcashdRpcUrl = String.fromEnvironment( + 'ZCASH_E2E_ZCASHD_RPC_URL', + defaultValue: 'http://127.0.0.1:18232', +); +const _zcashdRpcUser = 'zcash'; +const _zcashdRpcPassword = 'zcash'; +const _accountsKey = 'zcash_accounts'; +const _paymentUriChannel = 'com.zcash.wallet/payment_uri'; +const _firstMnemonic = + 'winter shiver fetch refuse absurd mail pistol eight market lounge manual ' + 'roast miracle ethics found child scare curve congress renew salute pig ' + 'better used'; +const _secondMnemonic = + 'return try reason flat civil wolf dwarf announce toddler uphold equip ' + 'range neck proof gauge east rifle swim tray twin venue fossil will ' + 'version'; +const _password = 'Vizor123!'; +final _currencyTicker = kZcashDefaultCurrencyTicker; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await initializeZcashWalletRuntime(); + }); + + testWidgets( + 'a zcash: payment URI opened while locked survives unlock and sends', + (tester) async { + addTearDown(() async { + await _cleanupE2eWalletState(); + }); + + await _cleanupE2eWalletState(); + + _log('pumping app'); + await tester.pumpWidget(await buildBootstrappedZcashWalletApp()); + + await _importFirstWallet(tester); + await _waitForBalance(tester, shielded: '1.25'); + + await _openAddAccountFlow(tester); + await _importAdditionalWallet(tester); + await _waitForHome(tester); + + _log('copying second account shielded address'); + final secondAddress = await _copyActiveShieldedAddress(tester); + expect(secondAddress, startsWith('uregtest1')); + final secondAccountUuid = await _accountUuidAtOrder(1); + + await _openWallet(tester); + await _switchAccount(tester, 0); + await _waitForBalance(tester, shielded: '1.25'); + await _waitForMempoolObserver(); + + // The heart of this test: a zcash: URI opened while the wallet is locked + // must survive the unlock screen and land on a prefilled send screen + // (regression guard for the locked-path fix), then send for real. + await _sendViaLockedPaymentUri(tester, secondAddress, '0.25'); + + await _openWallet(tester); + await _switchAccount(tester, 1); + await _waitForHistoryEntry( + tester, + accountUuid: secondAccountUuid, + txKind: 'receiving', + displayAmount: BigInt.from(25_000_000), + pending: true, + ); + _log('second account observed the incoming payment-URI transaction'); + + await _mineRegtestBlocks(10); + + await _openWallet(tester); + await _waitForBalance( + tester, + shielded: '0.25', + timeout: const Duration(minutes: 4), + ); + _log('second account received the payment-URI funds'); + + await _openWallet(tester); + await _switchAccount(tester, 0); + await _expectActivityRow( + tester, + const ValueKey('home_desktop_activity_row_0'), + title: 'Sent', + amount: '-0.25 $_currencyTicker', + status: 'Completed', + ); + _log('first account sent activity matched'); + }, + timeout: const Timeout(Duration(minutes: 10)), + ); +} + +/// Locks the wallet, opens a `zcash:` link while locked, unlocks, and asserts +/// the parked prefill is delivered to the prefilled send screen (not the +/// default /home) before driving the send to completion. +Future _sendViaLockedPaymentUri( + WidgetTester tester, + String address, + String amount, +) async { + // Lock the wallet first, then open the link while locked. + await _signOut(tester); + + final uri = 'zcash:$address?amount=$amount'; + _log('injecting payment URI while locked: $uri'); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _paymentUriChannel, + const StandardMethodCodec().encodeMethodCall( + MethodCall('onUris', [uri]), + ), + (_) {}, + ); + await tester.pump(const Duration(milliseconds: 250)); + + // The link must not bypass the lock screen: we stay on /unlock with the + // prefill parked. + expect( + tester.any(find.byKey(const ValueKey('unlock_password_field'))), + isTrue, + reason: + 'a payment URI opened while locked must keep the unlock screen showing', + ); + + // Unlock. The unlock flow must claim the parked prefill and route to the + // prefilled /send screen instead of the default /home (regression guard for + // the locked-path fix). + await _enterText( + tester, + const ValueKey('unlock_password_field'), + _password, + ); + await _tapAppButton( + tester, + const ValueKey('unlock_submit_button'), + timeout: const Duration(minutes: 1), + ); + + await _pumpUntil( + tester, + () => + _editableTextEquals( + tester, + const ValueKey('send_address_field'), + address, + ) && + _editableTextEquals( + tester, + const ValueKey('send_amount_field'), + amount, + ), + description: + 'unlock to deliver the parked payment URI to the prefilled send screen', + timeout: const Duration(minutes: 1), + ); + _log('locked-path: unlock delivered the prefill to the send screen'); + + await _tapAppButton( + tester, + const ValueKey('send_review_button'), + timeout: const Duration(minutes: 1), + ); + await _tapAppButton( + tester, + const ValueKey('send_confirm_button'), + timeout: const Duration(minutes: 1), + ); + await _pumpUntil( + tester, + () => tester.any(find.byKey(const ValueKey('send_status_completed'))), + description: 'send status to succeed', + timeout: const Duration(minutes: 4), + ); + _log('payment-URI send succeeded'); +} + +/// Locks the wallet via the sidebar "Sign out" action and waits for the unlock +/// screen. `_handleSignOut` calls `securityNotifier.lock()` and routes to +/// /unlock with no confirmation dialog. +Future _signOut(WidgetTester tester) async { + _log('signing out to lock the wallet'); + await _tapWidget(tester, const ValueKey('sidebar_sign_out_button')); + await _pumpUntil( + tester, + () => tester.any(find.byKey(const ValueKey('unlock_password_field'))), + description: 'unlock screen to show after sign out', + timeout: const Duration(minutes: 1), + ); + _log('wallet locked; unlock screen shown'); +} + +Future _importFirstWallet(WidgetTester tester) async { + _log('importing first wallet'); + await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button')); + await _enterText( + tester, + const ValueKey('import_mnemonic_first_word_field'), + _firstMnemonic, + ); + await _tapAppButton(tester, const ValueKey('import_secret_submit_button')); + await _tapAppButton(tester, const ValueKey('import_birthday_skip_button')); + await _tapAppButton( + tester, + const ValueKey('unknown_birthday_confirm_button'), + ); + await _enterText( + tester, + const ValueKey('set_password_password_field'), + _password, + ); + await _enterText( + tester, + const ValueKey('set_password_confirm_field'), + _password, + ); + await _tapAppButton(tester, const ValueKey('set_password_submit_button')); + await _waitForHome(tester); + _log('first wallet imported'); +} + +Future _importAdditionalWallet(WidgetTester tester) async { + _log('importing second wallet'); + await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button')); + await _enterText( + tester, + const ValueKey('import_mnemonic_first_word_field'), + _secondMnemonic, + ); + await _tapAppButton(tester, const ValueKey('import_secret_submit_button')); + await _tapAppButton(tester, const ValueKey('import_birthday_skip_button')); + await _tapAppButton( + tester, + const ValueKey('unknown_birthday_confirm_button'), + ); + await _waitForHome(tester); + _log('second wallet imported'); +} + +Future _openAddAccountFlow(WidgetTester tester) async { + _log('opening add-account flow'); + await _tapWidget(tester, const ValueKey('sidebar_accounts_button')); + await _tapWidget(tester, const ValueKey('sidebar_accounts_add')); + await _pumpUntil( + tester, + () => + tester.any(find.byKey(const ValueKey('welcome_import_wallet_button'))), + description: 'add-account welcome import button', + ); +} + +Future _copyActiveShieldedAddress(WidgetTester tester) async { + await _tapReceiveButton(tester); + await _pumpUntil( + tester, + () => tester.any( + find.byKey(const ValueKey('receive_copy_shielded_address_button')), + ), + description: 'shielded receive copy button', + ); + await _tapWidget( + tester, + const ValueKey('receive_copy_shielded_address_button'), + ); + final data = await Clipboard.getData('text/plain'); + final address = data?.text?.trim() ?? ''; + if (address.isEmpty) { + fail('Shielded address was not copied to the clipboard.'); + } + return address; +} + +Future _mineRegtestBlocks(int blocks) async { + _log('mining $blocks regtest blocks'); + + final before = await _zcashdRpc('getblockcount'); + await _zcashdRpc>('generate', [blocks]); + final targetHeight = before + blocks; + final deadline = DateTime.now().add(const Duration(seconds: 30)); + + while (DateTime.now().isBefore(deadline)) { + final lightwalletdHeight = await rust_wallet.getLatestBlockHeight( + lightwalletdUrl: _lightwalletdUrl, + ); + if (lightwalletdHeight.toInt() >= targetHeight) { + _log('lightwalletd reached mined height $targetHeight'); + return; + } + await Future.delayed(const Duration(seconds: 1)); + } + + throw StateError('Timed out waiting for lightwalletd height $targetHeight.'); +} + +Future _zcashdRpc( + String method, [ + List params = const [], +]) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(_zcashdRpcUrl)); + final credentials = base64Encode( + utf8.encode('$_zcashdRpcUser:$_zcashdRpcPassword'), + ); + request.headers + ..set(HttpHeaders.authorizationHeader, 'Basic $credentials') + ..contentType = ContentType.json; + request.write( + jsonEncode({ + 'jsonrpc': '1.0', + 'id': 'regtest-e2e', + 'method': method, + 'params': params, + }), + ); + + final response = await request.close(); + final body = await utf8.decoder.bind(response).join(); + if (response.statusCode != HttpStatus.ok) { + throw StateError('zcashd RPC $method failed: HTTP ${response.statusCode}'); + } + + final decoded = jsonDecode(body) as Map; + final error = decoded['error']; + if (error != null) { + throw StateError('zcashd RPC $method failed: $error'); + } + return decoded['result'] as T; + } finally { + client.close(force: true); + } +} + +Future _openWallet(WidgetTester tester) async { + await _tapWidget(tester, const ValueKey('sidebar_home_button')); + await _waitForHome(tester); +} + +Future _switchAccount(WidgetTester tester, int accountOrder) async { + _log('switching to account order $accountOrder'); + final accountUuid = await _accountUuidAtOrder(accountOrder); + await _tapWidget(tester, const ValueKey('sidebar_accounts_button')); + await _tapWidget(tester, ValueKey('sidebar_account_popover_row_$accountUuid')); + await _waitForHome(tester); +} + +Future _waitForHome(WidgetTester tester) async { + await _pumpUntil( + tester, + () => tester.any( + find.byKey(const ValueKey('home_desktop_balance_amount_text')), + ), + description: 'home balance card to render', + timeout: const Duration(minutes: 1), + ); +} + +Future _waitForMempoolObserver() async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + if (rust_sync.isMempoolObserverRunning()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('Timed out waiting for mempool observer to run.'); +} + +Future _accountUuidAtOrder(int order) async { + final rawAccounts = await AppSecureStore.instance.readString(_accountsKey); + if (rawAccounts == null || rawAccounts.trim().isEmpty) { + fail('Expected stored accounts before reading account order $order.'); + } + + final decoded = jsonDecode(rawAccounts); + if (decoded is! List) { + fail('Expected stored accounts to be a JSON list.'); + } + + final accounts = []; + for (final entry in decoded) { + if (entry is! Map) { + fail('Expected stored account entry to be a JSON object.'); + } + accounts.add(AccountInfo.fromJson(Map.from(entry))); + } + accounts.sort((a, b) => a.order.compareTo(b.order)); + + if (order >= accounts.length) { + fail('Expected account order $order, got ${accounts.length} accounts.'); + } + return accounts[order].uuid; +} + +Future _waitForHistoryEntry( + WidgetTester tester, { + required String accountUuid, + required String txKind, + required BigInt displayAmount, + required bool pending, +}) async { + final dbPath = await getWalletDbPath(); + final deadline = DateTime.now().add(const Duration(minutes: 2)); + Object? lastError; + var lastHistorySummary = ''; + + while (DateTime.now().isBefore(deadline)) { + try { + final history = await rust_sync.getTransactionHistory( + dbPath: dbPath, + network: _network, + limit: 20, + accountUuid: accountUuid, + ); + lastHistorySummary = history + .map( + (tx) => + '${tx.txidHex}:${tx.txKind}:${tx.displayAmount}:' + 'mined=${tx.minedHeight}:expired=${tx.expiredUnmined}', + ) + .join(', '); + if (history.any( + (tx) => + tx.txKind == txKind && + tx.displayAmount == displayAmount && + (tx.minedHeight == BigInt.zero) == pending && + !tx.expiredUnmined, + )) { + _log('history matched $txKind tx amount=$displayAmount'); + return; + } + } catch (e) { + lastError = e; + } + + await tester.pump(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); + } + + final error = lastError == null ? '' : ' Last error: $lastError'; + fail( + 'Timed out waiting for history $txKind amount=$displayAmount ' + 'pending=$pending. Observed history: $lastHistorySummary.$error', + ); +} + +Future _waitForBalance( + WidgetTester tester, { + String? shielded, + Duration timeout = const Duration(minutes: 4), +}) async { + if (shielded != null) { + await _pumpUntil( + tester, + () => _keyedTextEquals( + tester, + const ValueKey('home_desktop_balance_amount_text'), + shielded, + ), + description: 'shielded balance to show $shielded', + timeout: timeout, + ); + _log('shielded balance matched: $shielded'); + } +} + +bool _activityRowMatches( + Set texts, + String title, + String amount, + String status, +) { + if (!texts.contains(amount)) return false; + final titleOk = texts.contains(title) || texts.contains('$title ...'); + if (!titleOk) return false; + const knownStatuses = {'In progress', 'Completed', 'Failed', 'Refunded'}; + final rendered = texts.where(knownStatuses.contains); + return rendered.isEmpty || rendered.contains(status); +} + +Future _expectActivityRow( + WidgetTester tester, + Key key, { + required String title, + required String amount, + required String status, +}) async { + await _pumpUntil( + tester, + () => _activityRowMatches( + _textSetIn(tester, find.byKey(key)), + title, + amount, + status, + ), + description: '$key activity row to show $title $amount $status', + timeout: const Duration(minutes: 2), + ); + _log('activity row matched: $title $amount $status'); +} + +Future _cleanupE2eWalletState() async { + if (kZcashDefaultNetworkName != ZcashNetwork.regtest.name) { + throw StateError( + 'Refusing to clean wallet state without ZCASH_DEFAULT_NETWORK=regtest.', + ); + } + + final storage = AppSecureStore.instance; + final dbName = await getWalletDbName(); + + _log('cleaning regtest wallet state'); + await _stopRustWorkForCleanup(); + + await storage.deleteAll(); + + final supportDir = await getWalletSupportDirectory(); + if (!supportDir.existsSync()) return; + + for (final name in [dbName, '$dbName-shm', '$dbName-wal']) { + final file = File('${supportDir.path}${Platform.pathSeparator}$name'); + if (file.existsSync()) file.deleteSync(); + } +} + +Future _stopRustWorkForCleanup() async { + rust_sync.setSyncMode(mode: 0); + rust_sync.cancelFullSync(); + rust_sync.stopMempoolObserver(); + + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while ((rust_sync.isSyncRunning() || rust_sync.isMempoolObserverRunning()) && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (rust_sync.isSyncRunning() || rust_sync.isMempoolObserverRunning()) { + _log( + 'timed out waiting for Rust work to stop; continuing E2E storage cleanup', + ); + } +} + +Future _tapAppButton( + WidgetTester tester, + Key key, { + Duration timeout = const Duration(seconds: 20), +}) async { + final finder = find.byKey(key); + await _pumpUntil( + tester, + () => + tester.any(finder) && + tester.widget(finder).onPressed != null, + description: '$key button to be enabled', + timeout: timeout, + ); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 250)); + _log('tapped $key'); +} + +Future _tapWidget( + WidgetTester tester, + Key key, { + Duration timeout = const Duration(seconds: 20), +}) async { + final finder = find.byKey(key); + await _pumpUntil( + tester, + () => tester.any(finder), + description: '$key widget to render', + timeout: timeout, + ); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 250)); + _log('tapped $key'); +} + +Future _tapReceiveButton(WidgetTester tester) async { + const regular = ValueKey('home_desktop_receive_button'); + const first = ValueKey('home_desktop_receive_first_button'); + await _pumpUntil( + tester, + () => tester.any(find.byKey(regular)) || tester.any(find.byKey(first)), + description: 'a home receive button to render', + ); + await _tapWidget( + tester, + tester.any(find.byKey(regular)) ? regular : first, + ); +} + +Future _enterText(WidgetTester tester, Key key, String text) async { + final editable = find.descendant( + of: find.byKey(key), + matching: find.byType(EditableText), + ); + await _pumpUntil( + tester, + () => tester.any(editable), + description: '$key editable text field', + ); + await tester.tap(editable); + await tester.enterText(editable, text); + await tester.pump(const Duration(milliseconds: 100)); + _log('entered text into $key'); +} + +bool _keyedTextEquals(WidgetTester tester, Key key, String expected) { + final finder = find.byKey(key); + if (!tester.any(finder)) return false; + return tester.widget(finder).data == expected; +} + +bool _editableTextEquals(WidgetTester tester, Key key, String expected) { + final editable = find.descendant( + of: find.byKey(key), + matching: find.byType(EditableText), + ); + if (!tester.any(editable)) return false; + return tester.widget(editable).controller.text == expected; +} + +Set _textSetIn(WidgetTester tester, Finder finder) { + if (!tester.any(finder)) return const {}; + final texts = find.descendant(of: finder, matching: find.byType(Text)); + return tester + .widgetList(texts) + .map((text) => text.data) + .whereType() + .toSet(); +} + +Future _pumpUntil( + WidgetTester tester, + bool Function() condition, { + required String description, + Duration timeout = const Duration(seconds: 20), +}) async { + final end = DateTime.now().add(timeout); + Object? lastError; + var polls = 0; + while (DateTime.now().isBefore(end)) { + try { + if (condition()) return; + } catch (e) { + lastError = e; + } + await tester.pump(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); + polls++; + if (polls % 25 == 0) { + _log('still waiting for $description'); + } + } + + final error = lastError == null ? '' : ' Last error: $lastError'; + fail('Timed out waiting for $description.$error'); +} + +void _log(String message) { + debugPrint('[regtest-payment-uri-locked-e2e] $message'); +} diff --git a/integration_test/regtest_payment_uri_send_test.dart b/integration_test/regtest_payment_uri_send_test.dart new file mode 100644 index 000000000..8a6b894f2 --- /dev/null +++ b/integration_test/regtest_payment_uri_send_test.dart @@ -0,0 +1,657 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:zcash_wallet/app.dart'; +import 'package:zcash_wallet/src/core/config/network_config.dart'; +import 'package:zcash_wallet/src/core/storage/app_secure_store.dart'; +import 'package:zcash_wallet/src/core/storage/wallet_paths.dart'; +import 'package:zcash_wallet/src/core/widgets/app_button.dart'; +import 'package:zcash_wallet/src/providers/account_models.dart'; +import 'package:zcash_wallet/src/rust/api/sync.dart' as rust_sync; +import 'package:zcash_wallet/src/rust/api/wallet.dart' as rust_wallet; + +// End-to-end regtest coverage for the ZIP-321 payment-URI feature: opening a +// `zcash:
?amount=...` link must prefill the send screen and produce a +// real, mineable shielded transaction. The native deep-link delivery is +// simulated by pushing an `onUris` call over the `com.zcash.wallet/payment_uri` +// MethodChannel (the same contract the macOS/Windows/Linux/Android/iOS runners +// implement), so this exercises the Dart consumer + ZIP-321 parser + send flow +// against the live regtest network with funds actually moving. + +const _network = String.fromEnvironment( + 'ZCASH_E2E_NETWORK', + defaultValue: 'regtest', +); +const _lightwalletdUrl = String.fromEnvironment( + 'ZCASH_E2E_LIGHTWALLETD_URL', + defaultValue: 'http://127.0.0.1:9067', +); +const _zcashdRpcUrl = String.fromEnvironment( + 'ZCASH_E2E_ZCASHD_RPC_URL', + defaultValue: 'http://127.0.0.1:18232', +); +const _zcashdRpcUser = 'zcash'; +const _zcashdRpcPassword = 'zcash'; +const _accountsKey = 'zcash_accounts'; +const _paymentUriChannel = 'com.zcash.wallet/payment_uri'; +const _firstMnemonic = + 'winter shiver fetch refuse absurd mail pistol eight market lounge manual ' + 'roast miracle ethics found child scare curve congress renew salute pig ' + 'better used'; +const _secondMnemonic = + 'return try reason flat civil wolf dwarf announce toddler uphold equip ' + 'range neck proof gauge east rifle swim tray twin venue fossil will ' + 'version'; +const _password = 'Vizor123!'; +final _currencyTicker = kZcashDefaultCurrencyTicker; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await initializeZcashWalletRuntime(); + }); + + testWidgets( + 'opening a zcash: payment URI prefills and sends shielded funds', + (tester) async { + addTearDown(() async { + await _cleanupE2eWalletState(); + }); + + await _cleanupE2eWalletState(); + + _log('pumping app'); + await tester.pumpWidget(await buildBootstrappedZcashWalletApp()); + + await _importFirstWallet(tester); + await _waitForBalance(tester, shielded: '1.25'); + + await _openAddAccountFlow(tester); + await _importAdditionalWallet(tester); + await _waitForHome(tester); + + _log('copying second account shielded address'); + final secondAddress = await _copyActiveShieldedAddress(tester); + expect(secondAddress, startsWith('uregtest1')); + final secondAccountUuid = await _accountUuidAtOrder(1); + + await _openWallet(tester); + await _switchAccount(tester, 0); + await _waitForBalance(tester, shielded: '1.25'); + await _waitForMempoolObserver(); + + // The heart of this test: a zcash: URI must drive the send screen, not a + // manually typed address/amount. + await _sendViaPaymentUri(tester, secondAddress, '0.25'); + + await _openWallet(tester); + await _switchAccount(tester, 1); + await _waitForHistoryEntry( + tester, + accountUuid: secondAccountUuid, + txKind: 'receiving', + displayAmount: BigInt.from(25_000_000), + pending: true, + ); + _log('second account observed the incoming payment-URI transaction'); + + await _mineRegtestBlocks(10); + + await _openWallet(tester); + await _waitForBalance( + tester, + shielded: '0.25', + timeout: const Duration(minutes: 4), + ); + _log('second account received the payment-URI funds'); + + await _openWallet(tester); + await _switchAccount(tester, 0); + await _expectActivityRow( + tester, + const ValueKey('home_desktop_activity_row_0'), + title: 'Sent', + amount: '-0.25 $_currencyTicker', + status: 'Completed', + ); + _log('first account sent activity matched'); + }, + timeout: const Timeout(Duration(minutes: 10)), + ); +} + +/// Simulates the native side delivering a `zcash:` deep link, then asserts the +/// send screen is prefilled from it and drives the send to completion. +Future _sendViaPaymentUri( + WidgetTester tester, + String address, + String amount, +) async { + final uri = 'zcash:$address?amount=$amount'; + _log('injecting payment URI: $uri'); + + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _paymentUriChannel, + const StandardMethodCodec().encodeMethodCall( + MethodCall('onUris', [uri]), + ), + (_) {}, + ); + + // The URI is parsed and drained to /send with the address + amount prefilled. + await _pumpUntil( + tester, + () => + _editableTextEquals( + tester, + const ValueKey('send_address_field'), + address, + ) && + _editableTextEquals( + tester, + const ValueKey('send_amount_field'), + amount, + ), + description: 'payment URI to prefill the send address + amount', + timeout: const Duration(minutes: 1), + ); + _log('send screen prefilled from payment URI'); + + await _tapAppButton( + tester, + const ValueKey('send_review_button'), + timeout: const Duration(minutes: 1), + ); + await _tapAppButton( + tester, + const ValueKey('send_confirm_button'), + timeout: const Duration(minutes: 1), + ); + await _pumpUntil( + tester, + () => tester.any(find.byKey(const ValueKey('send_status_completed'))), + description: 'send status to succeed', + timeout: const Duration(minutes: 4), + ); + _log('payment-URI send succeeded'); +} + +Future _importFirstWallet(WidgetTester tester) async { + _log('importing first wallet'); + await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button')); + await _enterText( + tester, + const ValueKey('import_mnemonic_first_word_field'), + _firstMnemonic, + ); + await _tapAppButton(tester, const ValueKey('import_secret_submit_button')); + await _tapAppButton(tester, const ValueKey('import_birthday_skip_button')); + await _tapAppButton( + tester, + const ValueKey('unknown_birthday_confirm_button'), + ); + await _enterText( + tester, + const ValueKey('set_password_password_field'), + _password, + ); + await _enterText( + tester, + const ValueKey('set_password_confirm_field'), + _password, + ); + await _tapAppButton(tester, const ValueKey('set_password_submit_button')); + await _waitForHome(tester); + _log('first wallet imported'); +} + +Future _importAdditionalWallet(WidgetTester tester) async { + _log('importing second wallet'); + await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button')); + await _enterText( + tester, + const ValueKey('import_mnemonic_first_word_field'), + _secondMnemonic, + ); + await _tapAppButton(tester, const ValueKey('import_secret_submit_button')); + await _tapAppButton(tester, const ValueKey('import_birthday_skip_button')); + await _tapAppButton( + tester, + const ValueKey('unknown_birthday_confirm_button'), + ); + await _waitForHome(tester); + _log('second wallet imported'); +} + +Future _openAddAccountFlow(WidgetTester tester) async { + _log('opening add-account flow'); + await _tapWidget(tester, const ValueKey('sidebar_accounts_button')); + await _tapWidget(tester, const ValueKey('sidebar_accounts_add')); + await _pumpUntil( + tester, + () => + tester.any(find.byKey(const ValueKey('welcome_import_wallet_button'))), + description: 'add-account welcome import button', + ); +} + +Future _copyActiveShieldedAddress(WidgetTester tester) async { + await _tapReceiveButton(tester); + await _pumpUntil( + tester, + () => tester.any( + find.byKey(const ValueKey('receive_copy_shielded_address_button')), + ), + description: 'shielded receive copy button', + ); + await _tapWidget( + tester, + const ValueKey('receive_copy_shielded_address_button'), + ); + final data = await Clipboard.getData('text/plain'); + final address = data?.text?.trim() ?? ''; + if (address.isEmpty) { + fail('Shielded address was not copied to the clipboard.'); + } + return address; +} + +Future _mineRegtestBlocks(int blocks) async { + _log('mining $blocks regtest blocks'); + + final before = await _zcashdRpc('getblockcount'); + await _zcashdRpc>('generate', [blocks]); + final targetHeight = before + blocks; + final deadline = DateTime.now().add(const Duration(seconds: 30)); + + while (DateTime.now().isBefore(deadline)) { + final lightwalletdHeight = await rust_wallet.getLatestBlockHeight( + lightwalletdUrl: _lightwalletdUrl, + ); + if (lightwalletdHeight.toInt() >= targetHeight) { + _log('lightwalletd reached mined height $targetHeight'); + return; + } + await Future.delayed(const Duration(seconds: 1)); + } + + throw StateError('Timed out waiting for lightwalletd height $targetHeight.'); +} + +Future _zcashdRpc( + String method, [ + List params = const [], +]) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(_zcashdRpcUrl)); + final credentials = base64Encode( + utf8.encode('$_zcashdRpcUser:$_zcashdRpcPassword'), + ); + request.headers + ..set(HttpHeaders.authorizationHeader, 'Basic $credentials') + ..contentType = ContentType.json; + request.write( + jsonEncode({ + 'jsonrpc': '1.0', + 'id': 'regtest-e2e', + 'method': method, + 'params': params, + }), + ); + + final response = await request.close(); + final body = await utf8.decoder.bind(response).join(); + if (response.statusCode != HttpStatus.ok) { + throw StateError('zcashd RPC $method failed: HTTP ${response.statusCode}'); + } + + final decoded = jsonDecode(body) as Map; + final error = decoded['error']; + if (error != null) { + throw StateError('zcashd RPC $method failed: $error'); + } + return decoded['result'] as T; + } finally { + client.close(force: true); + } +} + +Future _openWallet(WidgetTester tester) async { + await _tapWidget(tester, const ValueKey('sidebar_home_button')); + await _waitForHome(tester); +} + +Future _switchAccount(WidgetTester tester, int accountOrder) async { + _log('switching to account order $accountOrder'); + final accountUuid = await _accountUuidAtOrder(accountOrder); + await _tapWidget(tester, const ValueKey('sidebar_accounts_button')); + await _tapWidget(tester, ValueKey('sidebar_account_popover_row_$accountUuid')); + await _waitForHome(tester); +} + +Future _waitForHome(WidgetTester tester) async { + await _pumpUntil( + tester, + () => tester.any( + find.byKey(const ValueKey('home_desktop_balance_amount_text')), + ), + description: 'home balance card to render', + timeout: const Duration(minutes: 1), + ); +} + +Future _waitForMempoolObserver() async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (DateTime.now().isBefore(deadline)) { + if (rust_sync.isMempoolObserverRunning()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('Timed out waiting for mempool observer to run.'); +} + +Future _accountUuidAtOrder(int order) async { + final rawAccounts = await AppSecureStore.instance.readString(_accountsKey); + if (rawAccounts == null || rawAccounts.trim().isEmpty) { + fail('Expected stored accounts before reading account order $order.'); + } + + final decoded = jsonDecode(rawAccounts); + if (decoded is! List) { + fail('Expected stored accounts to be a JSON list.'); + } + + final accounts = []; + for (final entry in decoded) { + if (entry is! Map) { + fail('Expected stored account entry to be a JSON object.'); + } + accounts.add(AccountInfo.fromJson(Map.from(entry))); + } + accounts.sort((a, b) => a.order.compareTo(b.order)); + + if (order >= accounts.length) { + fail('Expected account order $order, got ${accounts.length} accounts.'); + } + return accounts[order].uuid; +} + +Future _waitForHistoryEntry( + WidgetTester tester, { + required String accountUuid, + required String txKind, + required BigInt displayAmount, + required bool pending, +}) async { + final dbPath = await getWalletDbPath(); + final deadline = DateTime.now().add(const Duration(minutes: 2)); + Object? lastError; + var lastHistorySummary = ''; + + while (DateTime.now().isBefore(deadline)) { + try { + final history = await rust_sync.getTransactionHistory( + dbPath: dbPath, + network: _network, + limit: 20, + accountUuid: accountUuid, + ); + lastHistorySummary = history + .map( + (tx) => + '${tx.txidHex}:${tx.txKind}:${tx.displayAmount}:' + 'mined=${tx.minedHeight}:expired=${tx.expiredUnmined}', + ) + .join(', '); + if (history.any( + (tx) => + tx.txKind == txKind && + tx.displayAmount == displayAmount && + (tx.minedHeight == BigInt.zero) == pending && + !tx.expiredUnmined, + )) { + _log('history matched $txKind tx amount=$displayAmount'); + return; + } + } catch (e) { + lastError = e; + } + + await tester.pump(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); + } + + final error = lastError == null ? '' : ' Last error: $lastError'; + fail( + 'Timed out waiting for history $txKind amount=$displayAmount ' + 'pending=$pending. Observed history: $lastHistorySummary.$error', + ); +} + +Future _waitForBalance( + WidgetTester tester, { + String? shielded, + Duration timeout = const Duration(minutes: 4), +}) async { + if (shielded != null) { + await _pumpUntil( + tester, + () => _keyedTextEquals( + tester, + const ValueKey('home_desktop_balance_amount_text'), + shielded, + ), + description: 'shielded balance to show $shielded', + timeout: timeout, + ); + _log('shielded balance matched: $shielded'); + } +} + +bool _activityRowMatches( + Set texts, + String title, + String amount, + String status, +) { + if (!texts.contains(amount)) return false; + final titleOk = texts.contains(title) || texts.contains('$title ...'); + if (!titleOk) return false; + const knownStatuses = {'In progress', 'Completed', 'Failed', 'Refunded'}; + final rendered = texts.where(knownStatuses.contains); + return rendered.isEmpty || rendered.contains(status); +} + +Future _expectActivityRow( + WidgetTester tester, + Key key, { + required String title, + required String amount, + required String status, +}) async { + await _pumpUntil( + tester, + () => _activityRowMatches( + _textSetIn(tester, find.byKey(key)), + title, + amount, + status, + ), + description: '$key activity row to show $title $amount $status', + timeout: const Duration(minutes: 2), + ); + _log('activity row matched: $title $amount $status'); +} + +Future _cleanupE2eWalletState() async { + if (kZcashDefaultNetworkName != ZcashNetwork.regtest.name) { + throw StateError( + 'Refusing to clean wallet state without ZCASH_DEFAULT_NETWORK=regtest.', + ); + } + + final storage = AppSecureStore.instance; + final dbName = await getWalletDbName(); + + _log('cleaning regtest wallet state'); + await _stopRustWorkForCleanup(); + + await storage.deleteAll(); + + final supportDir = await getWalletSupportDirectory(); + if (!supportDir.existsSync()) return; + + for (final name in [dbName, '$dbName-shm', '$dbName-wal']) { + final file = File('${supportDir.path}${Platform.pathSeparator}$name'); + if (file.existsSync()) file.deleteSync(); + } +} + +Future _stopRustWorkForCleanup() async { + rust_sync.setSyncMode(mode: 0); + rust_sync.cancelFullSync(); + rust_sync.stopMempoolObserver(); + + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while ((rust_sync.isSyncRunning() || rust_sync.isMempoolObserverRunning()) && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (rust_sync.isSyncRunning() || rust_sync.isMempoolObserverRunning()) { + _log( + 'timed out waiting for Rust work to stop; continuing E2E storage cleanup', + ); + } +} + +Future _tapAppButton( + WidgetTester tester, + Key key, { + Duration timeout = const Duration(seconds: 20), +}) async { + final finder = find.byKey(key); + await _pumpUntil( + tester, + () => + tester.any(finder) && + tester.widget(finder).onPressed != null, + description: '$key button to be enabled', + timeout: timeout, + ); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 250)); + _log('tapped $key'); +} + +Future _tapWidget( + WidgetTester tester, + Key key, { + Duration timeout = const Duration(seconds: 20), +}) async { + final finder = find.byKey(key); + await _pumpUntil( + tester, + () => tester.any(finder), + description: '$key widget to render', + timeout: timeout, + ); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 250)); + _log('tapped $key'); +} + +Future _tapReceiveButton(WidgetTester tester) async { + const regular = ValueKey('home_desktop_receive_button'); + const first = ValueKey('home_desktop_receive_first_button'); + await _pumpUntil( + tester, + () => tester.any(find.byKey(regular)) || tester.any(find.byKey(first)), + description: 'a home receive button to render', + ); + await _tapWidget( + tester, + tester.any(find.byKey(regular)) ? regular : first, + ); +} + +Future _enterText(WidgetTester tester, Key key, String text) async { + final editable = find.descendant( + of: find.byKey(key), + matching: find.byType(EditableText), + ); + await _pumpUntil( + tester, + () => tester.any(editable), + description: '$key editable text field', + ); + await tester.tap(editable); + await tester.enterText(editable, text); + await tester.pump(const Duration(milliseconds: 100)); + _log('entered text into $key'); +} + +bool _keyedTextEquals(WidgetTester tester, Key key, String expected) { + final finder = find.byKey(key); + if (!tester.any(finder)) return false; + return tester.widget(finder).data == expected; +} + +bool _editableTextEquals(WidgetTester tester, Key key, String expected) { + final editable = find.descendant( + of: find.byKey(key), + matching: find.byType(EditableText), + ); + if (!tester.any(editable)) return false; + return tester.widget(editable).controller.text == expected; +} + +Set _textSetIn(WidgetTester tester, Finder finder) { + if (!tester.any(finder)) return const {}; + final texts = find.descendant(of: finder, matching: find.byType(Text)); + return tester + .widgetList(texts) + .map((text) => text.data) + .whereType() + .toSet(); +} + +Future _pumpUntil( + WidgetTester tester, + bool Function() condition, { + required String description, + Duration timeout = const Duration(seconds: 20), +}) async { + final end = DateTime.now().add(timeout); + Object? lastError; + var polls = 0; + while (DateTime.now().isBefore(end)) { + try { + if (condition()) return; + } catch (e) { + lastError = e; + } + await tester.pump(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); + polls++; + if (polls % 25 == 0) { + _log('still waiting for $description'); + } + } + + final error = lastError == null ? '' : ' Last error: $lastError'; + fail('Timed out waiting for $description.$error'); +} + +void _log(String message) { + debugPrint('[regtest-payment-uri-e2e] $message'); +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 2aad543c8..325413ac3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -165,6 +165,27 @@ import UIKit binaryMessenger: messenger ) screenshotChannel.setStreamHandler(ScreenshotStreamHandler()) + + // MethodChannel for ZIP-321 payment URIs (zcash:). Mirrors the desktop + // com.zcash.wallet/payment_uri contract (takePendingUris / ready / onUris). + // The scene delegate feeds inbound URLs into PaymentUriChannelBridge; this + // app uses the UIScene lifecycle, so application(_:open:) is never called. + let paymentUriChannel = FlutterMethodChannel( + name: "com.zcash.wallet/payment_uri", + binaryMessenger: messenger + ) + PaymentUriChannelBridge.shared.attach(channel: paymentUriChannel) + paymentUriChannel.setMethodCallHandler { (call, result) in + switch call.method { + case "takePendingUris": + result(PaymentUriChannelBridge.shared.takePending()) + case "ready": + PaymentUriChannelBridge.shared.markReady() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } } } @@ -195,3 +216,62 @@ class ScreenshotStreamHandler: NSObject, FlutterStreamHandler { return nil } } + +/// Buffers inbound `zcash:` payment URIs until Dart signals readiness, then +/// flushes them over `com.zcash.wallet/payment_uri`. Mirrors the macOS +/// `PaymentUriChannel`. Fed by `SceneDelegate` (this app uses the UIScene +/// lifecycle, so `application(_:open:)` never fires). Lives in this file so it +/// needs no project.pbxproj entry. All access is on the main thread. +final class PaymentUriChannelBridge { + static let shared = PaymentUriChannelBridge() + private init() {} + + private var channel: FlutterMethodChannel? + private var pendingUris: [String] = [] + private var dartReady = false + + func attach(channel: FlutterMethodChannel) { + self.channel = channel + // Re-tie readiness to this channel's lifetime: a fresh Flutter engine (new + // implicit engine -> didInitializeImplicitFlutterEngine -> attach) means the + // new Dart isolate will register its handler and call `ready` again. The + // other platforms gate the ready flag on channel/engine lifetime; matching + // that here keeps a URI that arrives before the new Dart handler is set up + // buffered (delivered via takePendingUris) instead of pushed via onUris and + // lost. + dartReady = false + } + + func markReady() { + dartReady = true + flush() + } + + func takePending() -> [String] { + let uris = pendingUris + pendingUris.removeAll() + return uris + } + + /// Extracts `zcash:` URLs from the contexts, buffers them, and flushes if + /// Dart is ready. Returns `true` when at least one `zcash:` URL was consumed. + @discardableResult + func handle(urlContexts: Set) -> Bool { + let strings = urlContexts.compactMap { context -> String? in + let url = context.url + guard url.scheme?.lowercased() == "zcash" else { return nil } + return url.absoluteString + } + guard !strings.isEmpty else { return false } + pendingUris.append(contentsOf: strings) + flush() + return true + } + + private func flush() { + guard dartReady, let channel, !pendingUris.isEmpty else { return } + let uris = pendingUris + pendingUris.removeAll() + channel.invokeMethod("onUris", arguments: uris) + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 907a74a0f..e8142ec13 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,6 +41,21 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).zcash + CFBundleURLSchemes + + zcash + + + + FlutterDeepLinkingEnabled + LSRequiresIPhoneOS UIApplicationSceneManifest diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index b9ce8ea2b..25a848d80 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -2,5 +2,27 @@ import Flutter import UIKit class SceneDelegate: FlutterSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + // A zcash: link that cold-starts Vizor arrives in the connection options' + // URL contexts. FlutterDeepLinkingEnabled is false, so super does not also + // route it; the payment-URI channel is the sole handler. + PaymentUriChannelBridge.shared.handle(urlContexts: connectionOptions.urlContexts) + } + override func scene( + _ scene: UIScene, + openURLContexts URLContexts: Set + ) { + PaymentUriChannelBridge.shared.handle(urlContexts: URLContexts) + // Forward anything we did not consume (non-zcash) to Flutter and plugins. + let remaining = URLContexts.filter { $0.url.scheme?.lowercased() != "zcash" } + if !remaining.isEmpty { + super.scene(scene, openURLContexts: Set(remaining)) + } + } } diff --git a/lib/app.dart b/lib/app.dart index 369b0443a..68607985e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -20,6 +20,7 @@ import 'src/core/theme/legacy_material_theme.dart'; import 'src/core/widgets/app_button.dart'; import 'src/core/widgets/app_icon.dart'; import 'src/core/widgets/network_fallback_toast.dart'; +import 'src/core/zcash/zip321_payment_request.dart'; import 'src/features/activity/screens/activity_screen.dart'; import 'src/features/activity/screens/activity_transaction_status_screen.dart'; import 'src/features/activity/screens/swap_activity_detail_screen.dart'; @@ -49,8 +50,8 @@ import 'src/features/onboarding/mobile/mobile_unlock_screen.dart'; import 'src/features/onboarding/unlock_screen.dart'; import 'src/features/onboarding/welcome.dart'; import 'src/features/receive/screens/receive_screen.dart'; -import 'src/features/send/models/send_prefill_args.dart'; import 'src/features/send/screens/keystone_send_scan_screen.dart'; +import 'src/features/send/models/send_prefill_args.dart'; import 'src/features/send/screens/send_review_screen.dart'; import 'src/features/send/screens/send_screen.dart'; import 'src/features/send/screens/send_status_screen.dart'; @@ -76,9 +77,11 @@ import 'src/providers/app_security_provider.dart'; import 'src/providers/linux_update_provider.dart'; import 'src/providers/rpc_endpoint_failover_provider.dart'; import 'src/providers/router_refresh_provider.dart'; +import 'src/providers/payment_uri_prefill_provider.dart'; import 'src/providers/wallet_provider.dart'; import 'src/providers/windows_update_provider.dart'; import 'src/rust/frb_generated.dart'; +import 'src/services/payment_uri_service.dart'; void log(String message) => debugPrint('[zcash] $message'); @@ -843,22 +846,25 @@ class ZcashWalletApp extends ConsumerWidget { child: _WindowsUpdateStartupCheck( child: _WindowsUpdatePromptHost( router: router, - child: _RpcEndpointFailoverToastListener( - child: _DesktopOpaqueWindowBackground( - child: GestureDetector( - onTap: () { - // Leaf-only: skip when the primary focus is a - // `FocusScopeNode` rather than a concrete `FocusNode`. - // Unfocusing the scope itself strips the scope's - // "most-recently-focused child" memory, which leaves the - // next Tab with no deterministic starting point. - final primary = FocusManager.instance.primaryFocus; - if (primary != null && primary is! FocusScopeNode) { - primary.unfocus(); - } - }, - behavior: HitTestBehavior.translucent, - child: child!, + child: _PaymentUriLinkListener( + router: router, + child: _RpcEndpointFailoverToastListener( + child: _DesktopOpaqueWindowBackground( + child: GestureDetector( + onTap: () { + // Leaf-only: skip when the primary focus is a + // `FocusScopeNode` rather than a concrete `FocusNode`. + // Unfocusing the scope itself strips the scope's + // "most-recently-focused child" memory, which leaves the + // next Tab with no deterministic starting point. + final primary = FocusManager.instance.primaryFocus; + if (primary != null && primary is! FocusScopeNode) { + primary.unfocus(); + } + }, + behavior: HitTestBehavior.translucent, + child: child!, + ), ), ), ), @@ -871,6 +877,145 @@ class ZcashWalletApp extends ConsumerWidget { } } +class _PaymentUriLinkListener extends ConsumerStatefulWidget { + const _PaymentUriLinkListener({required this.router, required this.child}); + + final GoRouter router; + final Widget child; + + @override + ConsumerState<_PaymentUriLinkListener> createState() => + _PaymentUriLinkListenerState(); +} + +class _PaymentUriLinkListenerState + extends ConsumerState<_PaymentUriLinkListener> { + StreamSubscription? _subscription; + var _paymentSequence = 0; + + @override + void initState() { + super.initState(); + unawaited(PaymentUriService.initialize()); + _subscription = PaymentUriService.uriStream.listen(_handlePaymentUri); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen>(walletProvider, (_, _) { + _schedulePendingDrain(); + }); + // No appSecurityProvider listener: the unlock screens own the post-unlock + // navigation for a parked prefill (claim + go to /send). Draining here on + // unlock too would race and clobber that navigation. The wallet listener + // still covers the loading -> loaded transition. + return widget.child; + } + + void _handlePaymentUri(String rawUri) { + try { + ref.read(paymentUriPrefillProvider.notifier).set(_prefillFromUri(rawUri)); + _schedulePendingDrain(); + } on Zip321ParseException catch (e) { + // Do not clear here: a failed parse of THIS link must not wipe a prefill + // already parked from an earlier valid link. + _showPaymentUriMessage(e.message); + } catch (e) { + log('Payment URI: failed to parse: $e'); + _showPaymentUriMessage('Payment link could not be opened.'); + } + } + + SendPrefillArgs _prefillFromUri(String rawUri) { + final request = Zip321PaymentRequest.parse(rawUri); + if (!request.isSupported) { + throw Zip321ParseException(request.unsupportedReason!); + } + final payment = request.primaryPayment; + return sendPrefillArgsFromZip321Payment( + id: 'payment-uri-${++_paymentSequence}', + payment: payment, + ); + } + + void _schedulePendingDrain() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _drainPendingPrefill(); + }); + } + + void _drainPendingPrefill() { + final prefill = ref.read(paymentUriPrefillProvider); + if (prefill == null) return; + + final bootstrap = ref.read(appBootstrapProvider); + if (bootstrap.hasBlockingFailure) return; + + final walletAsync = ref.read(walletProvider); + if (walletAsync.isLoading && walletAsync.value == null) return; + if (walletAsync.hasError) return; + + final wallet = walletAsync.value; + final hasWallet = wallet?.hasWallet ?? bootstrap.hasWallet; + if (!hasWallet) { + ref.read(paymentUriPrefillProvider.notifier).clear(); + widget.router.go('/welcome'); + _showPaymentUriMessage( + 'Set up or import a wallet before opening payment links.', + ); + return; + } + + final security = ref.read(appSecurityProvider); + if (!security.isUnlocked) { + // Leave the prefill parked in paymentUriPrefillProvider. The unlock flow + // claims it and routes to /send, so the payment intent is not lost when + // the link is opened while the wallet is locked. + widget.router.go('/unlock'); + return; + } + + // A link that arrives mid-unlock (wallet already unlocked but still on the + // unlock screen) is delivered by the unlock flow itself; navigating here + // too would clobber it. Defer and let the unlock screen claim it. + if (widget.router.state.matchedLocation == '/unlock') return; + + if (paymentUriBlockedAtLocation(widget.router.state.matchedLocation)) { + ref.read(paymentUriPrefillProvider.notifier).clear(); + _showPaymentUriMessage( + 'Finish or cancel your current send before opening another payment link.', + ); + return; + } + + ref.read(paymentUriPrefillProvider.notifier).clear(); + widget.router.go('/send', extra: prefill); + } + + void _showPaymentUriMessage(String message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 4)), + ); + }); + } +} + +bool paymentUriBlockedAtLocation(String matchedLocation) { + return matchedLocation == '/send' || matchedLocation.startsWith('/send/'); +} + class _WindowsUpdateStartupCheck extends ConsumerStatefulWidget { const _WindowsUpdateStartupCheck({required this.child}); diff --git a/lib/src/core/layout/app_main_sidebar.dart b/lib/src/core/layout/app_main_sidebar.dart index ee26e63e3..473435a73 100644 --- a/lib/src/core/layout/app_main_sidebar.dart +++ b/lib/src/core/layout/app_main_sidebar.dart @@ -430,6 +430,7 @@ class _AppMainSidebarState extends ConsumerState { ), const SizedBox(height: AppSpacing.xs), AppSidebarItem( + key: const ValueKey('sidebar_sign_out_button'), label: 'Sign out', iconName: AppIcons.logOut, onTap: _isSigningOut ? null : _handleSignOut, diff --git a/lib/src/core/navigation/mobile_routes.dart b/lib/src/core/navigation/mobile_routes.dart index 1f9ae052c..b7995102b 100644 --- a/lib/src/core/navigation/mobile_routes.dart +++ b/lib/src/core/navigation/mobile_routes.dart @@ -16,6 +16,7 @@ import '../../features/swap/models/swap_activity_navigation.dart'; import '../../features/swap/screens/mobile/mobile_swap_review_screen.dart'; import '../../features/send/services/send_flow.dart' show KeystoneBroadcastArgs, SendReviewArgs; +import '../../features/send/models/send_prefill_args.dart'; import '../../features/send/screens/mobile/mobile_send_screen.dart'; import '../../features/send/screens/mobile/mobile_send_status_screen.dart'; import '../../features/about/screens/mobile/mobile_about_screens.dart'; @@ -114,11 +115,21 @@ List buildMobileRoutes({required List entryRoutes}) { path: '/send', pageBuilder: (context, state) { final extra = state.extra; + // A ZIP-321 payment URI arrives as SendPrefillArgs (address + amount + + // memo); other callers still pass a bare recipient string. Unpack the + // prefill so the multi-step mobile flow lands on the address step (or + // the amount step when the URI carried an amount) with the fields + // populated, matching the desktop /send prefill behaviour. + final prefill = extra is SendPrefillArgs ? extra : null; return CupertinoPage( key: state.pageKey, child: MobileSendScreen( useRouteSteps: true, - initialRecipient: extra is String ? extra : null, + initialRecipient: + prefill?.address ?? (extra is String ? extra : null), + initialAmount: prefill?.amountText, + initialMemo: prefill?.memoText, + preserveInitialMemoWhitespace: prefill?.preserveMemoText ?? false, ), ); }, diff --git a/lib/src/core/zcash/zip321_payment_request.dart b/lib/src/core/zcash/zip321_payment_request.dart index b30e7a48a..32b058464 100644 --- a/lib/src/core/zcash/zip321_payment_request.dart +++ b/lib/src/core/zcash/zip321_payment_request.dart @@ -165,6 +165,7 @@ const _recognizedParamNames = { 'memo', 'req-asset', }; +const _maxMemoBase64UrlLength = 684; // ceil(512 / 3) * 4 class Zip321Payment { const Zip321Payment({ @@ -262,17 +263,52 @@ void _validateBase64Url(String value, String label) { ({String? text, bool isBinary}) _parseMemo(String value) { _validateBase64Url(value, 'memo'); + if (value.length > _maxMemoBase64UrlLength) { + throw const Zip321ParseException('ZIP-321 memo exceeds 512 bytes.'); + } final bytes = _decodeBase64UrlBytes(value, 'memo'); if (bytes.length > 512) { throw const Zip321ParseException('ZIP-321 memo exceeds 512 bytes.'); } try { - return (text: utf8.decode(bytes, allowMalformed: false), isBinary: false); + final text = utf8.decode(bytes, allowMalformed: false); + if (_containsUnsupportedMemoText(text)) { + throw const Zip321ParseException( + 'ZIP-321 memo contains unsupported control characters.', + ); + } + return (text: text, isBinary: false); } on FormatException { return (text: null, isBinary: true); } } +bool _containsUnsupportedMemoText(String value) => + value.runes.any(_isUnsupportedMemoCodePoint); + +bool _isUnsupportedMemoCodePoint(int codePoint) { + if (_bidiControlCodePoints.contains(codePoint)) return true; + if (codePoint < 0x20) { + return codePoint != 0x09 && codePoint != 0x0A && codePoint != 0x0D; + } + return codePoint >= 0x7F && codePoint <= 0x9F; +} + +const _bidiControlCodePoints = { + 0x061C, + 0x200E, + 0x200F, + 0x202A, + 0x202B, + 0x202C, + 0x202D, + 0x202E, + 0x2066, + 0x2067, + 0x2068, + 0x2069, +}; + List _decodeBase64UrlBytes(String value, String label) { final normalized = value.padRight( value.length + (4 - value.length % 4) % 4, diff --git a/lib/src/features/onboarding/mobile/mobile_unlock_screen.dart b/lib/src/features/onboarding/mobile/mobile_unlock_screen.dart index 8a61f34e8..15149dc8a 100644 --- a/lib/src/features/onboarding/mobile/mobile_unlock_screen.dart +++ b/lib/src/features/onboarding/mobile/mobile_unlock_screen.dart @@ -14,6 +14,7 @@ import '../../../providers/account_provider.dart'; import '../../../providers/app_security_provider.dart'; import '../../../providers/biometric_unlock_provider.dart'; import '../../../providers/device_owner_auth_provider.dart'; +import '../../../providers/payment_uri_prefill_provider.dart'; import '../../../providers/router_refresh_provider.dart'; import '../../../providers/sync_provider.dart'; import '../../../services/biometric_unlock.dart'; @@ -166,7 +167,16 @@ class _MobileUnlockScreenState extends ConsumerState { await syncNotifier.refreshAfterUnlock(); await syncNotifier.startSyncAnyway(); if (!mounted) return; - context.go('/home'); + // Claim the payment-URI prefill (parked while locked) only now, after + // the post-unlock work has succeeded. Claiming earlier would drop the + // payment if any of the awaits above threw or this screen unmounted. + final pendingPrefill = + ref.read(paymentUriPrefillProvider.notifier).takeIfFresh(); + if (pendingPrefill != null) { + context.go('/send', extra: pendingPrefill); + } else { + context.go('/home'); + } }); } catch (e, st) { log('MobileUnlockScreen._submit: ERROR: $e\n$st'); diff --git a/lib/src/features/onboarding/unlock_screen.dart b/lib/src/features/onboarding/unlock_screen.dart index 7a34e93f8..68b78b581 100644 --- a/lib/src/features/onboarding/unlock_screen.dart +++ b/lib/src/features/onboarding/unlock_screen.dart @@ -11,6 +11,7 @@ import '../../core/widgets/app_text_field.dart'; import '../../core/widgets/password_text_field.dart'; import '../../providers/account_provider.dart'; import '../../providers/app_security_provider.dart'; +import '../../providers/payment_uri_prefill_provider.dart'; import '../../providers/router_refresh_provider.dart'; import '../../providers/sync_provider.dart'; import 'shared/onboarding_auth_shell.dart'; @@ -71,7 +72,17 @@ class _UnlockScreenState extends ConsumerState { await syncNotifier.refreshAfterUnlock(); await syncNotifier.startSyncAnyway(); if (!mounted) return; - context.go('/home'); + // Claim the payment-URI prefill (parked while locked) only now, after + // the post-unlock work has succeeded. Claiming earlier would drop the + // payment if any of the awaits above threw or this screen unmounted — + // the prefill would already be cleared with no way to recover it. + final pendingPrefill = + ref.read(paymentUriPrefillProvider.notifier).takeIfFresh(); + if (pendingPrefill != null) { + context.go('/send', extra: pendingPrefill); + } else { + context.go('/home'); + } }); } catch (e, st) { log('UnlockScreen._submit: ERROR: $e\n$st'); @@ -201,6 +212,7 @@ class _UnlockContent extends StatelessWidget { width: _fieldWidth, height: _fieldGroupHeight, child: PasswordTextField( + key: const ValueKey('unlock_password_field'), label: 'Password', hintText: 'Enter password', showLabel: false, @@ -224,6 +236,7 @@ class _UnlockContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ AppButton( + key: const ValueKey('unlock_submit_button'), onPressed: canSubmit ? onSubmit : null, variant: AppButtonVariant.primary, minWidth: _buttonWidth, diff --git a/lib/src/features/send/models/send_prefill_args.dart b/lib/src/features/send/models/send_prefill_args.dart index c61acf0c9..3145c1111 100644 --- a/lib/src/features/send/models/send_prefill_args.dart +++ b/lib/src/features/send/models/send_prefill_args.dart @@ -1,3 +1,5 @@ +import '../../../core/zcash/zip321_payment_request.dart'; + class SendPrefillArgs { const SendPrefillArgs({ required this.id, @@ -5,6 +7,7 @@ class SendPrefillArgs { required this.address, this.amountText, this.memoText, + this.preserveMemoText = false, this.label, this.message, }); @@ -14,9 +17,26 @@ class SendPrefillArgs { final String address; final String? amountText; final String? memoText; + final bool preserveMemoText; final String? label; final String? message; String get fingerprint => - '$id|$address|${amountText ?? ''}|${memoText ?? ''}'; + '$id|$address|${amountText ?? ''}|${memoText ?? ''}|$preserveMemoText'; +} + +SendPrefillArgs sendPrefillArgsFromZip321Payment({ + required String id, + required Zip321Payment payment, +}) { + return SendPrefillArgs( + id: id, + source: 'zcash-uri', + address: payment.address, + amountText: payment.amount, + memoText: payment.memoText, + preserveMemoText: payment.memoText != null, + label: payment.label, + message: payment.message, + ); } diff --git a/lib/src/features/send/screens/mobile/mobile_send_screen.dart b/lib/src/features/send/screens/mobile/mobile_send_screen.dart index 5d2055cfc..e4a83c8ac 100644 --- a/lib/src/features/send/screens/mobile/mobile_send_screen.dart +++ b/lib/src/features/send/screens/mobile/mobile_send_screen.dart @@ -111,6 +111,8 @@ class MobileSendAmountArgs { required this.sendFlowId, required this.recipient, required this.addressType, + this.memo, + this.preserveMemoWhitespace = false, this.contactLabel, this.contactPictureId, }); @@ -118,6 +120,8 @@ class MobileSendAmountArgs { final String sendFlowId; final String recipient; final String addressType; + final String? memo; + final bool preserveMemoWhitespace; final String? contactLabel; final String? contactPictureId; } @@ -131,6 +135,7 @@ class MobileSendReviewDraftArgs { this.feeZatoshi, this.isMaxMode = false, this.memo, + this.preserveMemoWhitespace = false, this.contactLabel, this.contactPictureId, }); @@ -142,6 +147,7 @@ class MobileSendReviewDraftArgs { final BigInt? feeZatoshi; final bool isMaxMode; final String? memo; + final bool preserveMemoWhitespace; final String? contactLabel; final String? contactPictureId; } @@ -159,6 +165,8 @@ class MobileSendAmountScreen extends StatelessWidget { initialSendFlowId: args.sendFlowId, initialRecipient: args.recipient, initialAddressType: args.addressType, + initialMemo: args.memo, + preserveInitialMemoWhitespace: args.preserveMemoWhitespace, initialContactLabel: args.contactLabel, initialContactPictureId: args.contactPictureId, ); @@ -184,6 +192,7 @@ class MobileSendReviewScreen extends StatelessWidget { refreshReviewFeeOnInit: true, initialMaxMode: args.isMaxMode, initialMemo: args.memo, + preserveInitialMemoWhitespace: args.preserveMemoWhitespace, initialContactLabel: args.contactLabel, initialContactPictureId: args.contactPictureId, ); @@ -242,6 +251,7 @@ class MobileSendScreen extends ConsumerStatefulWidget { this.initialMaxMode = false, this.refreshReviewFeeOnInit = false, this.initialMemo, + this.preserveInitialMemoWhitespace = false, this.initialContactLabel, this.initialContactPictureId, this.initialRecipientFocused = false, @@ -267,6 +277,7 @@ class MobileSendScreen extends ConsumerStatefulWidget { final bool initialMaxMode; final bool refreshReviewFeeOnInit; final String? initialMemo; + final bool preserveInitialMemoWhitespace; final String? initialSendFlowId; final bool useRouteSteps; @@ -296,6 +307,10 @@ class _MobileSendScreenState extends ConsumerState { late final String _sendFlowId = widget.initialSendFlowId ?? newSendFlowId(); var _step = _SendStep.recipient; + // True when a ZIP-321 prefill jumped straight to the amount step while the + // address is still validating; lets us bounce back to the recipient step if + // the prefilled address turns out invalid (see _maybeFallBackToRecipientStep). + var _amountJumpPendingAddressCheck = false; var _phase = _SendPhase.compose; var _isConfirmingSend = false; @@ -316,6 +331,7 @@ class _MobileSendScreenState extends ConsumerState { // Review state. String _memo = ''; + bool _preserveMemoWhitespace = false; BigInt? _feeZatoshi; int _feeSeq = 0; @@ -327,11 +343,13 @@ class _MobileSendScreenState extends ConsumerState { super.initState(); _addressFocus.addListener(_handleAddressFocusChanged); final initial = widget.initialRecipient; + var hasInitialAddressType = false; if (initial != null && initial.trim().isNotEmpty) { _addressController.text = initial.trim(); final initialAddressType = widget.initialAddressType?.trim(); if (initialAddressType != null && initialAddressType.isNotEmpty) { _addressType = initialAddressType; + hasInitialAddressType = true; } else { unawaited(_validateAddress()); } @@ -342,11 +360,24 @@ class _MobileSendScreenState extends ConsumerState { } _contactPictureId = widget.initialContactPictureId; final initialMemo = widget.initialMemo; - if (initialMemo != null && initialMemo.trim().isNotEmpty) { - _memo = initialMemo.trim(); + if (initialMemo != null) { + final memo = widget.preserveInitialMemoWhitespace + ? initialMemo + : initialMemo.trim(); + if (memo.isNotEmpty) { + _memo = memo; + _preserveMemoWhitespace = widget.preserveInitialMemoWhitespace; + } } if (widget.initialAmountStep || widget.initialAmount != null) { _step = widget.initialReview ? _SendStep.review : _SendStep.amount; + // A ZIP-321 payment URI can prefill the amount and skip to the amount + // step; if the prefilled address validates as invalid, bounce back to the + // recipient step instead of letting the user continue past the error. + _amountJumpPendingAddressCheck = + !widget.initialReview && + widget.initialRecipient != null && + !hasInitialAddressType; _amountText = widget.initialAmount?.trim() ?? ''; _amountController.text = _amountText; _isMaxMode = widget.initialMaxMode; @@ -418,6 +449,11 @@ class _MobileSendScreenState extends ConsumerState { _step == _SendStep.recipient && _addressFocus.hasFocus; + // Null-safe route-pop check: the go_router context.canPop() extension calls + // GoRouter.of, which throws when there's no GoRouter in context (e.g. + // widgetbook galleries rendering this screen bare). maybeOf returns null there. + bool get _canPopRoute => GoRouter.maybeOf(context)?.canPop() ?? false; + bool get _routePopAllowed => _phase == _SendPhase.compose && (widget.useRouteSteps || _step == _SendStep.recipient) && @@ -443,13 +479,32 @@ class _MobileSendScreenState extends ConsumerState { address: address, ); if (!mounted || seq != _addressSeq) return; - setState( - () => _addressType = result.isValid ? result.addressType : 'invalid', - ); + setState(() { + _addressType = result.isValid ? result.addressType : 'invalid'; + _maybeFallBackToRecipientStep(); + }); } catch (e) { log('MobileSend: address validation error: $e'); if (!mounted || seq != _addressSeq) return; - setState(() => _addressType = 'error'); + setState(() { + _addressType = 'error'; + _maybeFallBackToRecipientStep(); + }); + } + } + + /// A ZIP-321 payment URI can jump straight to the amount step with the + /// address + amount prefilled. If the prefilled address then validates as + /// definitively invalid, fall back to the recipient step so the address error + /// is shown instead of letting the user continue past it. Only `'invalid'` + /// (validation ran and rejected the address) triggers this — a transient + /// `'error'` (validation itself failed, e.g. offline) is left alone and is + /// re-checked downstream at review/send. Runs once, for the initial prefill. + void _maybeFallBackToRecipientStep() { + if (!_amountJumpPendingAddressCheck) return; + _amountJumpPendingAddressCheck = false; + if (_step == _SendStep.amount && _addressType == 'invalid') { + _step = _SendStep.recipient; } } @@ -529,6 +584,8 @@ class _MobileSendScreenState extends ConsumerState { sendFlowId: _sendFlowId, recipient: _addressController.text.trim(), addressType: _addressType, + memo: _memo, + preserveMemoWhitespace: _preserveMemoWhitespace, contactLabel: _contactLabel, contactPictureId: _contactPictureId, ), @@ -776,6 +833,9 @@ class _MobileSendScreenState extends ConsumerState { bool get _amountReady => !_isResolvingMax && + _hasValidAddress && + !_amountJumpPendingAddressCheck && + !_isHardwareTexRecipient && _amountError == null && (parseZecAmount(_amountText.trim()) ?? BigInt.zero) > BigInt.zero && (!_isMaxMode || _hasCurrentMaxQuote); @@ -795,6 +855,7 @@ class _MobileSendScreenState extends ConsumerState { feeZatoshi: _feeZatoshi, isMaxMode: _isMaxMode && _hasCurrentMaxQuote, memo: _memo, + preserveMemoWhitespace: _preserveMemoWhitespace, contactLabel: _contactLabel, contactPictureId: _contactPictureId, ), @@ -809,7 +870,10 @@ class _MobileSendScreenState extends ConsumerState { // ── Review step ──────────────────────────────────────────────────── - String get _effectiveMemo => _isShieldedAddress ? _memo.trim() : ''; + String get _effectiveMemo { + if (!_isShieldedAddress) return ''; + return _preserveMemoWhitespace ? _memo : _memo.trim(); + } Future _refreshReviewQuote() { if (_isMaxMode) return _resolveMaxEstimate(); @@ -849,7 +913,10 @@ class _MobileSendScreenState extends ConsumerState { builder: (_) => _MemoSheet(initial: _memo), ); if (next == null || !mounted) return; - setState(() => _memo = next); + setState(() { + _memo = next; + _preserveMemoWhitespace = false; + }); unawaited(_refreshReviewQuote()); } @@ -971,11 +1038,27 @@ class _MobileSendScreenState extends ConsumerState { } switch (_step) { case _SendStep.recipient: - context.pop(); + // First compose step: pop to wherever we came from, or fall back to + // /home when there's nothing to pop. A payment-URI deep link can make + // /send the navigation root, leaving the back button with nowhere to go. + if (_canPopRoute) { + context.pop(); + } else { + context.go('/home'); + } case _SendStep.amount: _amountFocus.unfocus(); if (widget.useRouteSteps) { - context.pop(); + if (_canPopRoute) { + // Normal flow: amount is a pushed /send/amount page — pop it back + // to the recipient page. + context.pop(); + } else { + // A prefilled-amount deep link landed on the amount step of the + // root /send route (no page to pop), so popping is a dead end. + // Step back to recipient in place; the address is already filled. + setState(() => _step = _SendStep.recipient); + } return; } setState(() => _step = _SendStep.recipient); @@ -1823,7 +1906,7 @@ class _MobileSendScreenState extends ConsumerState { const SizedBox(height: AppSpacing.base), _ReviewWrap( isShielded: _isShieldedAddress, - memo: _memo.trim(), + memo: _effectiveMemo, feeText: feeText, onMemoTap: () => unawaited(_editMemo()), onFeeInfoTap: () => unawaited(_showFeeInfo()), diff --git a/lib/src/features/send/screens/send_screen.dart b/lib/src/features/send/screens/send_screen.dart index bf0c7fce9..f15a5a7d0 100644 --- a/lib/src/features/send/screens/send_screen.dart +++ b/lib/src/features/send/screens/send_screen.dart @@ -45,8 +45,26 @@ class SendScreen extends ConsumerStatefulWidget { } class _SendScreenState extends ConsumerState { + SendPrefillArgs? _retainedPrefill; + + @override + void initState() { + super.initState(); + _retainedPrefill = widget.prefill; + } + + @override + void didUpdateWidget(covariant SendScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final prefill = widget.prefill; + if (prefill != null) { + _retainedPrefill = prefill; + } + } + @override Widget build(BuildContext context) { + final prefill = widget.prefill ?? _retainedPrefill; final walletAsync = ref.watch(walletProvider); final accountState = ref.watch(accountProvider).value; final activeAccountUuid = accountState?.activeAccountUuid; @@ -61,12 +79,12 @@ class _SendScreenState extends ConsumerState { final spendableBalance = sync.spendableBalance; return _SendComposeBody( - key: ValueKey('$activeAccountUuid:${widget.prefill?.fingerprint ?? ''}'), + key: ValueKey('$activeAccountUuid:${prefill?.fingerprint ?? ''}'), walletAsync: walletAsync, activeAccountUuid: activeAccountUuid, activeAccountIsHardware: activeAccountIsHardware, spendableBalance: spendableBalance, - prefill: widget.prefill, + prefill: prefill, ); } } @@ -176,11 +194,14 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { bool _isMaxMode = false; bool _isResolvingMax = false; bool _programmaticAmountEdit = false; + bool _programmaticMemoEdit = false; + bool _preserveMemoWhitespace = false; _MaxQuote? _maxQuote; Timer? _maxDebounceTimer; int _addressSeq = 0; int _maxSeq = 0; int _validateSeq = 0; + String? _appliedPrefillFingerprint; @override void initState() { @@ -196,23 +217,6 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { }); } - void _applyPrefill(SendPrefillArgs? prefill) { - if (prefill == null) return; - _addressController.text = prefill.address; - if (prefill.amountText != null) { - _amountController.text = prefill.amountText!; - _amountError = null; - } - if (prefill.memoText != null && prefill.memoText!.isNotEmpty) { - _memoController.text = prefill.memoText!; - _messageExpanded = true; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(_validateAddress()); - }); - } - @override void dispose() { _maxDebounceTimer?.cancel(); @@ -231,6 +235,9 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { } void _handleMemoChanged() { + if (!_programmaticMemoEdit) { + _preserveMemoWhitespace = false; + } if (_memoController.text.isNotEmpty && !_messageExpanded) { _messageExpanded = true; } @@ -264,9 +271,41 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { _handleAddressChanged(); } + void _applyPrefill(SendPrefillArgs? prefill) { + if (prefill == null || _appliedPrefillFingerprint == prefill.fingerprint) { + return; + } + _appliedPrefillFingerprint = prefill.fingerprint; + _maxDebounceTimer?.cancel(); + _addressController.text = prefill.address; + if (prefill.amountText != null) { + _amountController.text = prefill.amountText!; + _amountError = null; + } + final memoText = prefill.memoText; + if (memoText != null && memoText.isNotEmpty) { + _preserveMemoWhitespace = prefill.preserveMemoText; + _programmaticMemoEdit = true; + _memoController.text = memoText; + _programmaticMemoEdit = false; + _messageExpanded = true; + } else { + _preserveMemoWhitespace = false; + } + _isMaxMode = false; + _isResolvingMax = false; + _maxQuote = null; + _error = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_validateAddress()); + }); + } + @override void didUpdateWidget(covariant _SendComposeBody oldWidget) { super.didUpdateWidget(oldWidget); + _applyPrefill(widget.prefill); if (oldWidget.spendableBalance != widget.spendableBalance) { if (_isMaxMode) { _scheduleMaxEstimate(immediate: true); @@ -296,6 +335,7 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { _addressType = nextAddressType; if (_isTransparentLikeType(nextAddressType)) { _messageExpanded = false; + _preserveMemoWhitespace = false; } }); if (_isTransparentLikeType(nextAddressType) && @@ -369,8 +409,11 @@ class _SendComposeBodyState extends ConsumerState<_SendComposeBody> { bool _isTransparentLikeType(String addressType) => addressType == 'transparent' || addressType == 'tex'; - String get _effectiveMemo => - _isTransparentLikeAddress ? '' : _memoController.text.trim(); + String get _effectiveMemo { + if (_isTransparentLikeAddress) return ''; + final memo = _memoController.text; + return _preserveMemoWhitespace ? memo : memo.trim(); + } BigInt get _availableBalanceForCurrentAddress => widget.spendableBalance; String get _insufficientBalanceText => diff --git a/lib/src/providers/payment_uri_prefill_provider.dart b/lib/src/providers/payment_uri_prefill_provider.dart new file mode 100644 index 000000000..dd8bd14cb --- /dev/null +++ b/lib/src/providers/payment_uri_prefill_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/send/models/send_prefill_args.dart'; + +/// Holds a ZIP-321 payment-URI prefill that has been parsed from a `zcash:` +/// link but not yet delivered to the send screen. +/// +/// This exists so the prefill survives the lock screen. A `zcash:` link opened +/// while the wallet is locked routes to `/unlock` and parks the prefill here; +/// the unlock flow then claims it (via [PaymentUriPrefillNotifier.takeIfFresh]) +/// and navigates straight to `/send` instead of the default `/home`, so the +/// payment intent is not lost. When the wallet is already unlocked the +/// `_PaymentUriLinkListener` drains it directly. +class PaymentUriPrefillNotifier extends Notifier { + /// A parked prefill older than this is treated as stale and dropped on the + /// next unlock. Without it, a link opened then left parked (the user never + /// unlocks) would fire as a payment on a much later, unrelated unlock. + static const parkTtl = Duration(minutes: 10); + + DateTime? _parkedAtUtc; + + @override + SendPrefillArgs? build() => null; + + void set(SendPrefillArgs prefill) { + _parkedAtUtc = DateTime.now().toUtc(); + state = prefill; + } + + void clear() { + _parkedAtUtc = null; + state = null; + } + + /// Returns the pending prefill (if any) and clears it in one step. + SendPrefillArgs? take() { + final prefill = state; + clear(); + return prefill; + } + + /// Like [take], but returns null (while still clearing) when the parked + /// prefill is older than [parkTtl]. The unlock flow uses this so a stale + /// parked link is dropped rather than delivered as a payment on an unrelated + /// later unlock. + SendPrefillArgs? takeIfFresh() { + final prefill = state; + final parkedAt = _parkedAtUtc; + clear(); + if (prefill == null || parkedAt == null) return null; + if (DateTime.now().toUtc().difference(parkedAt) > parkTtl) return null; + return prefill; + } +} + +final paymentUriPrefillProvider = + NotifierProvider( + PaymentUriPrefillNotifier.new, + ); diff --git a/lib/src/services/payment_uri_service.dart b/lib/src/services/payment_uri_service.dart new file mode 100644 index 000000000..53dc20adb --- /dev/null +++ b/lib/src/services/payment_uri_service.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class PaymentUriService { + PaymentUriService._(); + + static const _channel = MethodChannel('com.zcash.wallet/payment_uri'); + static final _controller = StreamController.broadcast(); + static var _initialized = false; + + static Stream get uriStream => _controller.stream; + + static Future initialize() async { + if (_initialized || !_isSupportedPlatform) return; + _initialized = true; + + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'onUris': + _addUris(call.arguments); + default: + throw MissingPluginException('Unknown method ${call.method}'); + } + }); + + try { + final pending = await _channel.invokeMethod>( + 'takePendingUris', + ); + _addUris(pending); + await _channel.invokeMethod('ready'); + } on MissingPluginException { + // Platforms whose native runner does not install this channel land + // here; the payment-URI feature simply stays inert for them. + } + } + + static bool get _isSupportedPlatform { + if (kIsWeb) return false; + return switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => true, + _ => false, + }; + } + + static void _addUris(Object? arguments) { + if (arguments is String) { + _controller.add(arguments); + return; + } + if (arguments is Iterable) { + for (final item in arguments) { + if (item is String) _controller.add(item); + } + } + } +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index ec3324ce6..89a4f72ba 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -5,11 +5,17 @@ #include #endif +#include + #include "flutter/generated_plugin_registrant.h" struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + GtkWindow* main_window; + FlMethodChannel* payment_uri_channel; + GPtrArray* pending_payment_uris; + gboolean payment_uri_dart_ready; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -37,15 +43,88 @@ static void register_icon_theme_paths() { } } +static gboolean is_zcash_uri(const gchar* value) { + return value != nullptr && g_ascii_strncasecmp(value, "zcash:", 6) == 0; +} + +static void add_pending_payment_uri(MyApplication* self, const gchar* value) { + if (!is_zcash_uri(value)) { + return; + } + g_ptr_array_add(self->pending_payment_uris, g_strdup(value)); +} + +static FlValue* take_pending_payment_uris(MyApplication* self) { + FlValue* uris = fl_value_new_list(); + for (guint i = 0; i < self->pending_payment_uris->len; ++i) { + const gchar* uri = + static_cast(g_ptr_array_index(self->pending_payment_uris, i)); + fl_value_append_take(uris, fl_value_new_string(uri)); + } + g_ptr_array_set_size(self->pending_payment_uris, 0); + return uris; +} + +static void flush_pending_payment_uris(MyApplication* self) { + if (!self->payment_uri_dart_ready || self->payment_uri_channel == nullptr || + self->pending_payment_uris->len == 0) { + return; + } + + g_autoptr(FlValue) uris = take_pending_payment_uris(self); + fl_method_channel_invoke_method(self->payment_uri_channel, "onUris", uris, + nullptr, nullptr, nullptr); +} + +static void payment_uri_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + const gchar* method = fl_method_call_get_name(method_call); + g_autoptr(FlMethodResponse) response = nullptr; + + if (std::strcmp(method, "takePendingUris") == 0) { + g_autoptr(FlValue) uris = take_pending_payment_uris(self); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(uris)); + } else if (std::strcmp(method, "ready") == 0) { + self->payment_uri_dart_ready = TRUE; + flush_pending_payment_uris(self); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void register_payment_uri_channel(MyApplication* self, FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlEngine* engine = fl_view_get_engine(view); + self->payment_uri_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(engine), "com.zcash.wallet/payment_uri", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(self->payment_uri_channel, + payment_uri_method_call_cb, + self, nullptr); +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); register_icon_theme_paths(); gtk_window_set_default_icon_name(APP_ICON_NAME); + if (self->main_window != nullptr) { + gtk_window_present(self->main_window); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); gtk_window_set_icon_name(window, APP_ICON_NAME); + self->main_window = window; + g_object_add_weak_pointer(G_OBJECT(window), + reinterpret_cast(&self->main_window)); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu @@ -96,16 +175,31 @@ static void my_application_activate(GApplication* application) { gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + register_payment_uri_channel(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); } +// Implements GApplication::open. Reached only on the primary instance, when a +// later process forwards zcash: links over D-Bus. +static void my_application_open(GApplication* application, GFile** files, + gint n_files, const gchar* /*hint*/) { + MyApplication* self = MY_APPLICATION(application); + for (gint i = 0; i < n_files; ++i) { + g_autofree gchar* uri = g_file_get_uri(files[i]); + add_pending_payment_uri(self, uri); + } + flush_pending_payment_uris(self); + g_application_activate(application); +} + // Implements GApplication::local_command_line. static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; @@ -115,6 +209,28 @@ static gboolean my_application_local_command_line(GApplication* application, return TRUE; } + if (g_application_get_is_remote(application)) { + g_autoptr(GPtrArray) files = g_ptr_array_new_with_free_func(g_object_unref); + for (gchar** argument = self->dart_entrypoint_arguments; + argument != nullptr && *argument != nullptr; ++argument) { + if (is_zcash_uri(*argument)) { + g_ptr_array_add(files, g_file_new_for_uri(*argument)); + } + } + if (files->len > 0) { + g_application_open(application, reinterpret_cast(files->pdata), + static_cast(files->len), ""); + } else { + g_application_activate(application); + } + *exit_status = 0; + return TRUE; + } + + for (gchar** argument = self->dart_entrypoint_arguments; + argument != nullptr && *argument != nullptr; ++argument) { + add_pending_payment_uri(self, *argument); + } g_application_activate(application); *exit_status = 0; @@ -142,12 +258,15 @@ static void my_application_shutdown(GApplication* application) { // Implements GObject::dispose. static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); + g_clear_object(&self->payment_uri_channel); + g_clear_pointer(&self->pending_payment_uris, g_ptr_array_unref); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->open = my_application_open; G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; G_APPLICATION_CLASS(klass)->startup = my_application_startup; @@ -155,7 +274,11 @@ static void my_application_class_init(MyApplicationClass* klass) { G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init(MyApplication* self) { + self->main_window = nullptr; + self->pending_payment_uris = g_ptr_array_new_with_free_func(g_free); + self->payment_uri_dart_ready = FALSE; +} MyApplication* my_application_new() { // Set the program name to the application ID, which helps various systems @@ -166,5 +289,5 @@ MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); + G_APPLICATION_HANDLES_OPEN, nullptr)); } diff --git a/linux/vizor.desktop.in b/linux/vizor.desktop.in index 95fcbd59b..107bc5a9b 100644 --- a/linux/vizor.desktop.in +++ b/linux/vizor.desktop.in @@ -2,8 +2,9 @@ Type=Application Name=@APP_DISPLAY_NAME@ Comment=Self-custody Zcash wallet -Exec=@BINARY_NAME@ +Exec=@BINARY_NAME@ %u Icon=@APPLICATION_ID@ Terminal=false Categories=Office;Finance; +MimeType=x-scheme-handler/zcash; StartupWMClass=@APPLICATION_ID@ diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 2d531d2d3..722c2989d 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -43,6 +43,10 @@ class AppDelegate: FlutterAppDelegate { #endif } + override func application(_ application: NSApplication, open urls: [URL]) { + PaymentUriChannel.handle(urls: urls) + } + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index df2a5543e..3252f78bf 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -10,6 +10,19 @@ $(MACOS_APP_ICON_FILE) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).zcash + CFBundleURLSchemes + + zcash + + + CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 5c39df93b..055becbf4 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -599,6 +599,77 @@ final class DeviceOwnerAuthChannel { } } +final class PaymentUriChannel { + private static var channel: FlutterMethodChannel? + private static var pendingURLs: [String] = [] + private static var dartReady = false + + static func register(messenger: FlutterBinaryMessenger) { + let methodChannel = FlutterMethodChannel( + name: "com.zcash.wallet/payment_uri", + binaryMessenger: messenger + ) + channel = methodChannel + methodChannel.setMethodCallHandler { call, result in + switch call.method { + case "takePendingUris": + let urls = pendingURLs + pendingURLs.removeAll() + result(urls) + case "ready": + dartReady = true + flushPendingURLs() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + static func handle(urls: [URL]) { + let urlStrings = urls.compactMap { url -> String? in + guard url.scheme?.lowercased() == "zcash" else { + return nil + } + return url.absoluteString + } + guard !urlStrings.isEmpty else { + return + } + pendingURLs.append(contentsOf: urlStrings) + flushPendingURLs() + presentMainWindow() + } + + private static func presentMainWindow() { + NSApp.activate(ignoringOtherApps: true) + guard let window = mainWindowForPaymentUri() else { + return + } + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + } + + private static func mainWindowForPaymentUri() -> NSWindow? { + return NSApp.mainWindow as? MainFlutterWindow + ?? NSApp.keyWindow as? MainFlutterWindow + ?? NSApp.windows.compactMap { $0 as? MainFlutterWindow }.first + ?? NSApp.mainWindow + ?? NSApp.keyWindow + } + + private static func flushPendingURLs() { + guard dartReady, let channel, !pendingURLs.isEmpty else { + return + } + let urls = pendingURLs + pendingURLs.removeAll() + channel.invokeMethod("onUris", arguments: urls) + } +} + class MainFlutterWindow: NSWindow { private let vizorWindowToolbarDelegate = VizorWindowToolbarDelegate() private var vizorWindowToolbar: NSToolbar? @@ -635,6 +706,9 @@ class MainFlutterWindow: NSWindow { DeviceOwnerAuthChannel.register( messenger: flutterViewController.engine.binaryMessenger ) + PaymentUriChannel.register( + messenger: flutterViewController.engine.binaryMessenger + ) RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() diff --git a/scripts/e2e/flutter-macos-payment-uri-prefill.sh b/scripts/e2e/flutter-macos-payment-uri-prefill.sh new file mode 100755 index 000000000..2a82fa44b --- /dev/null +++ b/scripts/e2e/flutter-macos-payment-uri-prefill.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FLUTTER_DEVICE="${FLUTTER_DEVICE:-macos}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd fvm + +cd "$ROOT_DIR" + +echo "running Flutter macOS payment URI prefill integration test" +fvm flutter test \ + integration_test/payment_uri_prefill_test.dart \ + -d "$FLUTTER_DEVICE" diff --git a/scripts/e2e/flutter-macos-regtest-payment-uri-locked-send.sh b/scripts/e2e/flutter-macos-regtest-payment-uri-locked-send.sh new file mode 100755 index 000000000..f3093d673 --- /dev/null +++ b/scripts/e2e/flutter-macos-regtest-payment-uri-locked-send.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MNEMONIC="winter shiver fetch refuse absurd mail pistol eight market lounge manual roast miracle ethics found child scare curve congress renew salute pig better used" +SHIELDED_AMOUNT="1.25" +CONFIRMING_BLOCKS="${E2E_CONFIRMING_BLOCKS:-10}" +LIGHTWALLETD_URL="${E2E_LIGHTWALLETD_URL:-http://127.0.0.1:9067}" +ZCASHD_RPC_URL="${E2E_ZCASHD_RPC_URL:-http://127.0.0.1:18232}" +FLUTTER_DEVICE="${FLUTTER_DEVICE:-macos}" +RESET_REGTEST="${RESET_REGTEST:-1}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +json_field() { + python3 - "$1" "$2" <<'PY' +import json +import sys + +data = json.loads(sys.argv[1]) +print(data[sys.argv[2]]) +PY +} + +require_cmd cargo +require_cmd docker +require_cmd fvm +require_cmd python3 + +cd "$ROOT_DIR" + +if [[ "$RESET_REGTEST" == "1" ]]; then + scripts/regtest/reset.sh +fi +scripts/regtest/up.sh + +addresses_json="$(cd rust && cargo run --quiet --example regtest_wallet_addresses -- "$MNEMONIC")" +unified_address="$(json_field "$addresses_json" unifiedAddress)" + +echo "funding shielded address with ${SHIELDED_AMOUNT} TAZ" +scripts/regtest/fund-wallet.sh "$unified_address" "$SHIELDED_AMOUNT" "$CONFIRMING_BLOCKS" >/dev/null + +echo "running Flutter macOS payment-URI LOCKED-path send integration test" +fvm flutter test \ + integration_test/regtest_payment_uri_locked_send_test.dart \ + -d "$FLUTTER_DEVICE" \ + --dart-define=ZCASH_DEFAULT_NETWORK=regtest \ + --dart-define=ZCASH_E2E_LIGHTWALLETD_URL="$LIGHTWALLETD_URL" \ + --dart-define=ZCASH_E2E_ZCASHD_RPC_URL="$ZCASHD_RPC_URL" diff --git a/scripts/e2e/flutter-macos-regtest-payment-uri-send.sh b/scripts/e2e/flutter-macos-regtest-payment-uri-send.sh new file mode 100755 index 000000000..4361c09a9 --- /dev/null +++ b/scripts/e2e/flutter-macos-regtest-payment-uri-send.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MNEMONIC="winter shiver fetch refuse absurd mail pistol eight market lounge manual roast miracle ethics found child scare curve congress renew salute pig better used" +SHIELDED_AMOUNT="1.25" +CONFIRMING_BLOCKS="${E2E_CONFIRMING_BLOCKS:-10}" +LIGHTWALLETD_URL="${E2E_LIGHTWALLETD_URL:-http://127.0.0.1:9067}" +ZCASHD_RPC_URL="${E2E_ZCASHD_RPC_URL:-http://127.0.0.1:18232}" +FLUTTER_DEVICE="${FLUTTER_DEVICE:-macos}" +RESET_REGTEST="${RESET_REGTEST:-1}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +json_field() { + python3 - "$1" "$2" <<'PY' +import json +import sys + +data = json.loads(sys.argv[1]) +print(data[sys.argv[2]]) +PY +} + +require_cmd cargo +require_cmd docker +require_cmd fvm +require_cmd python3 + +cd "$ROOT_DIR" + +if [[ "$RESET_REGTEST" == "1" ]]; then + scripts/regtest/reset.sh +fi +scripts/regtest/up.sh + +addresses_json="$(cd rust && cargo run --quiet --example regtest_wallet_addresses -- "$MNEMONIC")" +unified_address="$(json_field "$addresses_json" unifiedAddress)" + +echo "funding shielded address with ${SHIELDED_AMOUNT} TAZ" +scripts/regtest/fund-wallet.sh "$unified_address" "$SHIELDED_AMOUNT" "$CONFIRMING_BLOCKS" >/dev/null + +echo "running Flutter macOS payment-URI send integration test" +fvm flutter test \ + integration_test/regtest_payment_uri_send_test.dart \ + -d "$FLUTTER_DEVICE" \ + --dart-define=ZCASH_DEFAULT_NETWORK=regtest \ + --dart-define=ZCASH_E2E_LIGHTWALLETD_URL="$LIGHTWALLETD_URL" \ + --dart-define=ZCASH_E2E_ZCASHD_RPC_URL="$ZCASHD_RPC_URL" diff --git a/scripts/package-linux-appimage.sh b/scripts/package-linux-appimage.sh index 05565ab25..3a9804bec 100755 --- a/scripts/package-linux-appimage.sh +++ b/scripts/package-linux-appimage.sh @@ -164,7 +164,7 @@ DESKTOP_FILE="$APPDIR/$APP_ID.desktop" cp "$BUNDLE_DIR/data/applications/$APP_ID.desktop" "$DESKTOP_FILE" sed -i \ -e "s/^Name=.*/Name=$APP_NAME/" \ - -e "s/^Exec=.*/Exec=$BINARY_NAME/" \ + -e "s/^Exec=.*/Exec=$BINARY_NAME %u/" \ -e "s/^Icon=.*/Icon=$APP_ID/" \ -e "s/^Categories=.*/Categories=Office;Finance;/" \ -e "s/^StartupWMClass=.*/StartupWMClass=$APP_ID/" \ @@ -385,7 +385,7 @@ log_step "Running linuxdeploy" log_step "Writing custom AppRun" write_app_run -set_desktop_exec "AppRun" +set_desktop_exec "AppRun %u" install_appimage_root_icon log_step "Copying GStreamer runtime" diff --git a/test/app_payment_uri_policy_test.dart b/test/app_payment_uri_policy_test.dart new file mode 100644 index 000000000..752fe9d69 --- /dev/null +++ b/test/app_payment_uri_policy_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zcash_wallet/app.dart'; + +void main() { + test('payment URI links are blocked while a send flow is active', () { + for (final location in [ + '/send', + '/send/amount', + '/send/review', + '/send/status', + '/send/keystone-sign', + '/send/keystone/scan', + ]) { + expect(paymentUriBlockedAtLocation(location), isTrue, reason: location); + } + }); + + test('payment URI links are allowed outside send flows', () { + for (final location in ['/home', '/unlock', '/activity', '/settings']) { + expect(paymentUriBlockedAtLocation(location), isFalse, reason: location); + } + }); +} diff --git a/test/core/navigation/mobile_routes_test.dart b/test/core/navigation/mobile_routes_test.dart index 323ecb765..1b1555019 100644 --- a/test/core/navigation/mobile_routes_test.dart +++ b/test/core/navigation/mobile_routes_test.dart @@ -18,6 +18,7 @@ import 'package:zcash_wallet/src/core/theme/app_theme.dart'; import 'package:zcash_wallet/src/features/activity/screens/mobile/mobile_activity_screen.dart'; import 'package:zcash_wallet/src/features/home/screens/mobile/mobile_home_screen.dart'; import 'package:zcash_wallet/src/features/receive/screens/mobile/mobile_receive_screen.dart'; +import 'package:zcash_wallet/src/features/send/models/send_prefill_args.dart'; import 'package:zcash_wallet/src/features/send/screens/mobile/mobile_send_screen.dart'; import 'package:zcash_wallet/src/features/swap/screens/mobile/mobile_swap_screen.dart'; import 'package:zcash_wallet/src/providers/account_provider.dart'; @@ -135,6 +136,41 @@ void main() { expect(find.byType(MobileHomeScreen), findsOneWidget); }); + testWidgets( + 'a ZIP-321 SendPrefillArgs on /send populates the mobile send screen', + (tester) async { + final router = _router(); + await tester.pumpWidget(_app(router)); + await tester.pumpAndSettle(); + + unawaited( + router.push( + '/send', + extra: const SendPrefillArgs( + id: 'payment-uri-1', + source: 'zcash-uri', + address: 'u1routeraddress', + amountText: '0.25', + memoText: ' coffee ', + preserveMemoText: true, + ), + ), + ); + await tester.pumpAndSettle(); + + // The mobile /send route must unpack SendPrefillArgs (a ZIP-321 payment + // URI) into the recipient + amount + memo, not drop it like a bare + // recipient string would. + final sendScreen = tester.widget( + find.byType(MobileSendScreen), + ); + expect(sendScreen.initialRecipient, 'u1routeraddress'); + expect(sendScreen.initialAmount, '0.25'); + expect(sendScreen.initialMemo, ' coffee '); + expect(sendScreen.preserveInitialMemoWhitespace, isTrue); + }, + ); + testWidgets('send amount and review routes push Cupertino pages', ( tester, ) async { diff --git a/test/features/send/mobile_send_screen_test.dart b/test/features/send/mobile_send_screen_test.dart index d04530244..d5f52f758 100644 --- a/test/features/send/mobile_send_screen_test.dart +++ b/test/features/send/mobile_send_screen_test.dart @@ -34,6 +34,8 @@ Completer? _proposeSendCompleter; int _estimateSendMaxCalls = 0; String? _lastEstimateSendMaxToAddress; String? _lastEstimateSendMaxMemo; +String? _lastProposeToAddress; +String? _lastProposeMemo; _SendMaxEstimateBuilder? _sendMaxEstimateBuilder; typedef _SendMaxEstimateBuilder = @@ -107,6 +109,8 @@ class _RustApiFake implements RustLibApi { required BigInt amountZatoshi, String? memo, }) async { + _lastProposeToAddress = toAddress; + _lastProposeMemo = memo; final completer = _proposeSendCompleter; if (completer != null) return completer.future; if (!_proposeSendSucceeds) { @@ -180,7 +184,12 @@ Widget _app({ EdgeInsets viewPadding = EdgeInsets.zero, MobileSendScanner? openScanner, String? initialRecipient, + String? initialAmount, + bool initialAmountReady = false, + BigInt? initialFeeZatoshi, + String? initialMemo, MobileSendAddressValidator? validateAddress, + MobileSendFeeEstimator? estimateFee, }) { final router = GoRouter( initialLocation: '/send', @@ -191,7 +200,12 @@ Widget _app({ loadWalletDbPath: () async => '/tmp/zcash-test', openScanner: openScanner ?? (_) async => null, initialRecipient: initialRecipient, + initialAmount: initialAmount, + initialAmountReady: initialAmountReady, + initialFeeZatoshi: initialFeeZatoshi, + initialMemo: initialMemo, validateAddress: validateAddress, + estimateFee: estimateFee, ), ), GoRoute(path: '/home', builder: (_, _) => const Text('home')), @@ -226,7 +240,11 @@ Widget _app({ ); } -Widget _sendFlowRouterApp({MobileSendFeeEstimator? estimateFee}) { +Widget _sendFlowRouterApp({ + MobileSendFeeEstimator? estimateFee, + String? initialMemo, + bool preserveInitialMemoWhitespace = false, +}) { final router = GoRouter( initialLocation: '/home', routes: [ @@ -244,6 +262,8 @@ Widget _sendFlowRouterApp({MobileSendFeeEstimator? estimateFee}) { useRouteSteps: true, loadWalletDbPath: () async => '/tmp/zcash-test', openScanner: (_) async => null, + initialMemo: initialMemo, + preserveInitialMemoWhitespace: preserveInitialMemoWhitespace, estimateFee: estimateFee, ), ), @@ -257,6 +277,8 @@ Widget _sendFlowRouterApp({MobileSendFeeEstimator? estimateFee}) { initialSendFlowId: args.sendFlowId, initialRecipient: args.recipient, initialAddressType: args.addressType, + initialMemo: args.memo, + preserveInitialMemoWhitespace: args.preserveMemoWhitespace, initialContactLabel: args.contactLabel, initialContactPictureId: args.contactPictureId, loadWalletDbPath: () async => '/tmp/zcash-test', @@ -281,6 +303,7 @@ Widget _sendFlowRouterApp({MobileSendFeeEstimator? estimateFee}) { refreshReviewFeeOnInit: true, initialMaxMode: args.isMaxMode, initialMemo: args.memo, + preserveInitialMemoWhitespace: args.preserveMemoWhitespace, initialContactLabel: args.contactLabel, initialContactPictureId: args.contactPictureId, loadWalletDbPath: () async => '/tmp/zcash-test', @@ -407,6 +430,8 @@ void main() { _estimateSendMaxCalls = 0; _lastEstimateSendMaxToAddress = null; _lastEstimateSendMaxMemo = null; + _lastProposeToAddress = null; + _lastProposeMemo = null; _sendMaxEstimateBuilder = null; final binding = TestWidgetsFlutterBinding.ensureInitialized(); binding.platformDispatcher.views.first @@ -568,6 +593,30 @@ void main() { expect(find.text('Select Recipient'), findsOneWidget); }); + testWidgets('route-step mode preserves ZIP-321 memo whitespace on propose', ( + tester, + ) async { + const rawMemo = ' shielded memo '; + + await tester.pumpWidget( + _sendFlowRouterApp( + initialMemo: rawMemo, + preserveInitialMemoWhitespace: true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('mobile_send_open_from_home'))); + await tester.pumpAndSettle(); + + await _toReviewStep(tester); + await tester.tap(find.byKey(const ValueKey('mobile_send_confirm'))); + await tester.pumpAndSettle(); + + expect(_lastProposeToAddress, _shieldedAddress); + expect(_lastProposeMemo, rawMemo); + }); + testWidgets('route-step review refreshes the fee on entry', (tester) async { var feeCalls = 0; final refreshedFee = BigInt.from(30000); @@ -1247,6 +1296,50 @@ void main() { expect(find.text('Finish & review'), findsOneWidget); }); + testWidgets('prefilled amount waits for recipient validation before review', ( + tester, + ) async { + final validation = Completer(); + + await tester.pumpWidget( + _app( + initialRecipient: _shieldedAddress, + initialAmount: '1.5', + initialAmountReady: true, + initialFeeZatoshi: BigInt.from(10000), + initialMemo: 'shielded memo', + validateAddress: ({required address}) => validation.future, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Enter amount'), findsOneWidget); + final pendingReview = tester.widget( + find.byKey(const ValueKey('mobile_send_review_button')), + ); + expect(pendingReview.onPressed, isNull); + + await tester.tap(find.byKey(const ValueKey('mobile_send_review_button'))); + await tester.pump(); + expect(find.text('Review Send'), findsNothing); + + validation.complete( + const AddressValidationResult(isValid: true, addressType: 'unified'), + ); + await tester.pumpAndSettle(); + + final readyReview = tester.widget( + find.byKey(const ValueKey('mobile_send_review_button')), + ); + expect(readyReview.onPressed, isNotNull); + + await tester.tap(find.byKey(const ValueKey('mobile_send_review_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Review Send'), findsOneWidget); + expect(find.text('shielded memo'), findsOneWidget); + }); + testWidgets('review shows the receipt and the shielded memo entry', ( tester, ) async { diff --git a/test/features/send/send_prefill_args_test.dart b/test/features/send/send_prefill_args_test.dart new file mode 100644 index 000000000..ed72ff6d9 --- /dev/null +++ b/test/features/send/send_prefill_args_test.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:zcash_wallet/src/core/zcash/zip321_payment_request.dart'; +import 'package:zcash_wallet/src/features/send/models/send_prefill_args.dart'; + +void main() { + test('ZIP-321 memo text is preserved at the send prefill boundary', () { + final rawMemo = ' Pay invoice 42\nKeep emoji \u200D '; + final memo = base64Url.encode(utf8.encode(rawMemo)).replaceAll('=', ''); + final request = Zip321PaymentRequest.parse( + 'zcash:u1zip321destination?amount=1&memo=$memo', + ); + + final prefill = sendPrefillArgsFromZip321Payment( + id: 'payment-uri-test', + payment: request.primaryPayment, + ); + + expect(prefill.memoText, rawMemo); + expect(prefill.preserveMemoText, isTrue); + }); +} diff --git a/test/features/send/send_screen_test.dart b/test/features/send/send_screen_test.dart index 6b4ee6fdd..d9ff96599 100644 --- a/test/features/send/send_screen_test.dart +++ b/test/features/send/send_screen_test.dart @@ -78,6 +78,38 @@ void main() { expect(find.byKey(const ValueKey('send_review_button')), findsOneWidget); }); + testWidgets('preserves ZIP-321 memo whitespace when proposing send', ( + tester, + ) async { + await _setDesktopViewport(tester); + + const rawMemo = ' Donation note '; + await tester.pumpWidget( + _sendHarness( + prefill: const SendPrefillArgs( + id: 'zip321-whitespace', + source: 'zcash-uri', + address: _shieldedAddress, + amountText: '1.25', + memoText: rawMemo, + preserveMemoText: true, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('send_review_button'))); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + + expect(rustApi.proposeSendCalls, 1); + expect(rustApi.lastProposeMemo, rawMemo); + }); + testWidgets('contacts label fills the send address from zcash contacts', ( tester, ) async { diff --git a/test/features/send/zip321_payment_request_test.dart b/test/features/send/zip321_payment_request_test.dart new file mode 100644 index 000000000..af1701194 --- /dev/null +++ b/test/features/send/zip321_payment_request_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:zcash_wallet/src/core/zcash/zip321_payment_request.dart'; + +void main() { + test('parses a CipherPay-style ZIP-321 payment URI', () { + final memo = base64Url + .encode(utf8.encode('CP-C6CDB775')) + .replaceAll('=', ''); + + final request = Zip321PaymentRequest.parse( + 'zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez' + '?amount=0.12345678&memo=$memo&label=Acme%20Store', + ); + + expect(request.isSupported, isTrue); + expect(request.payments, hasLength(1)); + expect( + request.primaryPayment.address, + 'ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez', + ); + expect(request.primaryPayment.amount, '0.12345678'); + expect(request.primaryPayment.memoText, 'CP-C6CDB775'); + expect(request.primaryPayment.label, 'Acme Store'); + }); + + test('rejects unsupported required parameters', () { + expect( + () => Zip321PaymentRequest.parse( + 'zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?req-unknown=1', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Required ZIP-321 parameter req-unknown is not supported.', + ), + ), + ); + }); + + test('marks multiple-recipient requests as parsed but unsupported', () { + final request = Zip321PaymentRequest.parse( + 'zcash:?address=u1firstaddress&amount=1' + '&address.1=u1secondaddress&amount.1=2', + ); + + expect(request.payments, hasLength(2)); + expect(request.isSupported, isFalse); + expect( + request.unsupportedReason, + 'Multiple-recipient ZIP-321 requests are parsed but not supported yet.', + ); + }); + + test('rejects memo on transparent addresses', () { + final memo = base64Url.encode(utf8.encode('hello')).replaceAll('=', ''); + + expect( + () => Zip321PaymentRequest.parse('zcash:t1transparent?memo=$memo'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Transparent ZIP-321 payments cannot include a memo.', + ), + ), + ); + }); + + test('rejects oversized memo before base64 decoding', () { + final oversizedMemo = 'A' * 685; + + expect( + () => Zip321PaymentRequest.parse( + 'zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?memo=$oversizedMemo', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ZIP-321 memo exceeds 512 bytes.', + ), + ), + ); + }); + + test('rejects text memo with unsupported control characters', () { + final rawMemo = 'Pay \u202Eevil\u202C\u0001 now'; + final memo = base64Url.encode(utf8.encode(rawMemo)).replaceAll('=', ''); + + expect( + () => Zip321PaymentRequest.parse( + 'zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?memo=$memo', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ZIP-321 memo contains unsupported control characters.', + ), + ), + ); + }); +} diff --git a/test/services/payment_uri_service_test.dart b/test/services/payment_uri_service_test.dart new file mode 100644 index 000000000..a7d3bf74e --- /dev/null +++ b/test/services/payment_uri_service_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zcash_wallet/src/services/payment_uri_service.dart'; + +// Exercises the full native -> Dart contract of PaymentUriService: +// - cold start: initialize() must call `takePendingUris`, forward whatever the +// native side buffered, then call `ready`; +// - warm: a later native `onUris` push must be forwarded to the stream. +// PaymentUriService keeps process-global state (it initializes once), so this +// lives in a single test that covers both halves of the flow in order. +void main() { + const channel = MethodChannel('com.zcash.wallet/payment_uri'); + + test( + 'initialize drains takePendingUris and forwards a later onUris push', + () async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + // _isSupportedPlatform gates on defaultTargetPlatform; pin a supported one. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + final messenger = binding.defaultBinaryMessenger; + const codec = StandardMethodCodec(); + var readyCalled = false; + var takePendingCalls = 0; + + // Mock the Dart -> native side: native reports one buffered cold-start URI + // on takePendingUris, and acknowledges ready. + messenger.setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'takePendingUris': + takePendingCalls++; + return ['zcash:coldstart?amount=0.5']; + case 'ready': + readyCalled = true; + return null; + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + final received = []; + final sub = PaymentUriService.uriStream.listen(received.add); + addTearDown(sub.cancel); + + await PaymentUriService.initialize(); + await Future.delayed(Duration.zero); + + // Cold-start buffered URI was drained via takePendingUris, then ready fired. + expect(takePendingCalls, 1); + expect(readyCalled, isTrue); + expect(received, contains('zcash:coldstart?amount=0.5')); + + // A later native onUris push (warm path) is forwarded to the stream. + await messenger.handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + const MethodCall('onUris', ['zcash:warm?amount=0.25']), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + expect(received, contains('zcash:warm?amount=0.25')); + }, + ); +} diff --git a/test/widgetbook/mobile_send_use_cases_test.dart b/test/widgetbook/mobile_send_use_cases_test.dart index 71813690f..42a0143aa 100644 --- a/test/widgetbook/mobile_send_use_cases_test.dart +++ b/test/widgetbook/mobile_send_use_cases_test.dart @@ -63,7 +63,7 @@ void main() { ); expect( find.byKey(const ValueKey('mobile_send_address_field_placeholder')), - findsOneWidget, + findsNothing, ); expect( tester.getSize(find.byKey(const ValueKey('mobile_send_address_field'))), diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 4f881f9de..9a3147e33 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -9,6 +9,8 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" + "payment_uri_handoff.cpp" + "payment_uri_protocol.cpp" "utils.cpp" "velopack_uninstall.cpp" "velopack_update.cpp" diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index d060400c8..a4a100507 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -12,9 +12,11 @@ #include #include #include +#include #include #include "flutter/generated_plugin_registrant.h" +#include "payment_uri_handoff.h" #include "utils.h" #include "velopack_update.h" @@ -73,6 +75,15 @@ using MethodResult = using MethodResultPtr = std::unique_ptr; using SharedMethodResult = std::shared_ptr; +void PresentWindowForPaymentUri(HWND hwnd) { + if (::IsIconic(hwnd)) { + ::ShowWindow(hwnd, SW_RESTORE); + } else { + ::ShowWindow(hwnd, SW_SHOW); + } + ::SetForegroundWindow(hwnd); +} + void CompleteVerificationError(SharedMethodResult result, const std::string& code, const std::string& message) { @@ -378,8 +389,11 @@ void VerifyDeviceOwner( } // namespace -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} +FlutterWindow::FlutterWindow( + const flutter::DartProject& project, + std::vector initial_payment_uris) + : project_(project), + pending_payment_uris_(std::move(initial_payment_uris)) {} FlutterWindow::~FlutterWindow() {} @@ -432,6 +446,25 @@ bool FlutterWindow::OnCreate() { }); velopack_update_channel_ = CreateVelopackUpdateChannel(flutter_controller_->engine()->messenger()); + payment_uri_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), + "com.zcash.wallet/payment_uri", + &flutter::StandardMethodCodec::GetInstance()); + payment_uri_channel_->SetMethodCallHandler( + [this](const auto& call, auto result) { + if (call.method_name() == "takePendingUris") { + result->Success(TakePendingPaymentUris()); + return; + } + if (call.method_name() == "ready") { + payment_uri_dart_ready_ = true; + FlushPendingPaymentUris(); + result->Success(); + return; + } + result->NotImplemented(); + }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); @@ -452,6 +485,7 @@ void FlutterWindow::OnDestroy() { camera_permission_channel_.reset(); device_owner_auth_channel_.reset(); velopack_update_channel_.reset(); + payment_uri_channel_.reset(); flutter_controller_ = nullptr; } @@ -462,6 +496,16 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { + if (message == WM_COPYDATA) { + std::string payment_uri; + if (TryReadPaymentUriCopyData(lparam, &payment_uri)) { + pending_payment_uris_.push_back(std::move(payment_uri)); + PresentWindowForPaymentUri(hwnd); + FlushPendingPaymentUris(); + return TRUE; + } + } + // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = @@ -480,3 +524,24 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } + +flutter::EncodableValue FlutterWindow::TakePendingPaymentUris() { + flutter::EncodableList uris; + uris.reserve(pending_payment_uris_.size()); + for (const auto& uri : pending_payment_uris_) { + uris.emplace_back(uri); + } + pending_payment_uris_.clear(); + return flutter::EncodableValue(uris); +} + +void FlutterWindow::FlushPendingPaymentUris() { + if (!payment_uri_dart_ready_ || !payment_uri_channel_ || + pending_payment_uris_.empty()) { + return; + } + + payment_uri_channel_->InvokeMethod( + "onUris", std::make_unique( + TakePendingPaymentUris())); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 4ddbc49f2..41a20f2e2 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -6,6 +6,8 @@ #include #include +#include +#include #include "win32_window.h" @@ -13,7 +15,8 @@ class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); + explicit FlutterWindow(const flutter::DartProject& project, + std::vector initial_payment_uris); virtual ~FlutterWindow(); protected: @@ -36,6 +39,13 @@ class FlutterWindow : public Win32Window { device_owner_auth_channel_; std::unique_ptr> velopack_update_channel_; + std::unique_ptr> + payment_uri_channel_; + std::vector pending_payment_uris_; + bool payment_uri_dart_ready_ = false; + + flutter::EncodableValue TakePendingPaymentUris(); + void FlushPendingPaymentUris(); }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index d93ee3cea..6b084028c 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -4,6 +4,8 @@ #include #include "flutter_window.h" +#include "payment_uri_handoff.h" +#include "payment_uri_protocol.h" #include "utils.h" #include "velopack_uninstall.h" @@ -21,15 +23,26 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, // plugins. const HRESULT ro_init = ::RoInitialize(RO_INIT_SINGLETHREADED); const bool ro_initialized = SUCCEEDED(ro_init); + // Conditional: don't steal the zcash: handler from another wallet/channel on + // every launch. Install/update hooks (RunVelopackHooks) still claim it. + RegisterZcashProtocolHandlerIfUnclaimed(); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); + std::vector initial_payment_uris = + GetZcashUriArguments(command_line_arguments); + if (ForwardPaymentUrisToRunningInstance(initial_payment_uris)) { + if (ro_initialized) { + ::RoUninitialize(); + } + return EXIT_SUCCESS; + } project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - FlutterWindow window(project); + FlutterWindow window(project, std::move(initial_payment_uris)); Win32Window::Point origin(10, 10); Win32Window::Size size(1095, 726); if (!window.Create(L"Vizor", origin, size)) { diff --git a/windows/runner/payment_uri_handoff.cpp b/windows/runner/payment_uri_handoff.cpp new file mode 100644 index 000000000..afe02e718 --- /dev/null +++ b/windows/runner/payment_uri_handoff.cpp @@ -0,0 +1,162 @@ +#include "payment_uri_handoff.h" + +#include +#include +#include + +#include "utils.h" + +namespace { + +constexpr wchar_t kFlutterWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; +constexpr ULONG_PTR kPaymentUriCopyDataId = 0x5A43555249; // "ZCURI" + +std::wstring ToLower(std::wstring value) { + std::transform(value.begin(), value.end(), value.begin(), + [](wchar_t ch) { return static_cast(towlower(ch)); }); + return value; +} + +std::wstring ModuleFileName() { + std::wstring path(MAX_PATH, L'\0'); + while (true) { + const DWORD length = ::GetModuleFileNameW( + nullptr, path.data(), static_cast(path.size())); + if (length == 0) { + return L""; + } + if (length < path.size() - 1) { + path.resize(length); + return path; + } + path.resize(path.size() * 2); + } +} + +std::wstring ProcessImagePath(DWORD process_id) { + HANDLE process = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + process_id); + if (process == nullptr) { + return L""; + } + + std::wstring path(MAX_PATH, L'\0'); + DWORD length = static_cast(path.size()); + while (true) { + if (::QueryFullProcessImageNameW(process, 0, path.data(), &length)) { + ::CloseHandle(process); + path.resize(length); + return path; + } + if (::GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + ::CloseHandle(process); + return L""; + } + path.resize(path.size() * 2); + length = static_cast(path.size()); + } +} + +bool IsFlutterRunnerWindow(HWND hwnd) { + wchar_t class_name[256]; + const int length = ::GetClassNameW(hwnd, class_name, 256); + return length > 0 && std::wstring(class_name, length) == kFlutterWindowClassName; +} + +bool SendPaymentUri(HWND hwnd, const std::string& uri) { + if (!IsZcashUri(uri)) { + return false; + } + + COPYDATASTRUCT copy_data; + copy_data.dwData = kPaymentUriCopyDataId; + copy_data.cbData = static_cast(uri.size() + 1); + copy_data.lpData = const_cast(uri.c_str()); + + DWORD_PTR result = 0; + return ::SendMessageTimeoutW(hwnd, WM_COPYDATA, 0, + reinterpret_cast(©_data), + SMTO_ABORTIFHUNG, 3000, &result) != 0; +} + +bool SendPaymentUris(HWND hwnd, const std::vector& uris) { + bool delivered_any = false; + for (const auto& uri : uris) { + if (SendPaymentUri(hwnd, uri)) { + delivered_any = true; + } + } + return delivered_any; +} + +struct ForwardContext { + std::wstring module_path; + const std::vector* uris; + bool delivered = false; +}; + +BOOL CALLBACK ForwardToMatchingWindow(HWND hwnd, LPARAM lparam) { + auto* context = reinterpret_cast(lparam); + if (!IsFlutterRunnerWindow(hwnd)) { + return TRUE; + } + + DWORD process_id = 0; + ::GetWindowThreadProcessId(hwnd, &process_id); + if (process_id == 0 || process_id == ::GetCurrentProcessId()) { + return TRUE; + } + + const std::wstring process_path = ToLower(ProcessImagePath(process_id)); + if (process_path.empty() || process_path != context->module_path) { + return TRUE; + } + + ::AllowSetForegroundWindow(process_id); + context->delivered = SendPaymentUris(hwnd, *context->uris); + return context->delivered ? FALSE : TRUE; +} + +} // namespace + +bool ForwardPaymentUrisToRunningInstance(const std::vector& uris) { + if (uris.empty()) { + return false; + } + + ForwardContext context; + context.module_path = ToLower(ModuleFileName()); + context.uris = &uris; + if (context.module_path.empty()) { + return false; + } + + ::EnumWindows(ForwardToMatchingWindow, reinterpret_cast(&context)); + return context.delivered; +} + +bool TryReadPaymentUriCopyData(LPARAM lparam, std::string* uri) { + if (uri == nullptr) { + return false; + } + + const auto* copy_data = reinterpret_cast(lparam); + if (copy_data == nullptr || copy_data->dwData != kPaymentUriCopyDataId || + copy_data->lpData == nullptr || copy_data->cbData == 0 || + copy_data->cbData > kMaxZcashUriBytes + 1) { + return false; + } + + const auto* raw = static_cast(copy_data->lpData); + if (raw[copy_data->cbData - 1] != '\0') { + return false; + } + + std::string value(raw, copy_data->cbData - 1); + if (!IsZcashUri(value)) { + return false; + } + + *uri = std::move(value); + return true; +} diff --git a/windows/runner/payment_uri_handoff.h b/windows/runner/payment_uri_handoff.h new file mode 100644 index 000000000..c1f3401b2 --- /dev/null +++ b/windows/runner/payment_uri_handoff.h @@ -0,0 +1,17 @@ +#ifndef RUNNER_PAYMENT_URI_HANDOFF_H_ +#define RUNNER_PAYMENT_URI_HANDOFF_H_ + +#include + +#include +#include + +// Forwards zcash: payment URIs to an already-running Vizor instance from the +// same executable path. Returns true only when at least one URI was delivered. +bool ForwardPaymentUrisToRunningInstance(const std::vector& uris); + +// Decodes and validates a WM_COPYDATA payload produced by +// ForwardPaymentUrisToRunningInstance. +bool TryReadPaymentUriCopyData(LPARAM lparam, std::string* uri); + +#endif // RUNNER_PAYMENT_URI_HANDOFF_H_ diff --git a/windows/runner/payment_uri_protocol.cpp b/windows/runner/payment_uri_protocol.cpp new file mode 100644 index 000000000..abbf4d5e6 --- /dev/null +++ b/windows/runner/payment_uri_protocol.cpp @@ -0,0 +1,148 @@ +#include "payment_uri_protocol.h" + +#include + +#include +#include + +#include +#include +#include + +namespace { + +constexpr wchar_t kProtocolKeyPath[] = L"Software\\Classes\\zcash"; +constexpr wchar_t kProtocolCommandKeyPath[] = + L"Software\\Classes\\zcash\\shell\\open\\command"; +constexpr wchar_t kEffectiveProtocolCommandKeyPath[] = + L"zcash\\shell\\open\\command"; + +struct RegistryKey { + HKEY value = nullptr; + + ~RegistryKey() { + if (value != nullptr) { + ::RegCloseKey(value); + } + } +}; + +std::wstring ToLower(std::wstring value) { + std::transform(value.begin(), value.end(), value.begin(), + [](wchar_t ch) { return static_cast(towlower(ch)); }); + return value; +} + +std::wstring ModuleFileName() { + std::wstring path(MAX_PATH, L'\0'); + DWORD length = 0; + while (true) { + length = ::GetModuleFileNameW(nullptr, path.data(), + static_cast(path.size())); + if (length == 0) { + return L""; + } + if (length < path.size() - 1) { + path.resize(length); + return path; + } + path.resize(path.size() * 2); + } +} + +bool CreateCurrentUserKey(const wchar_t* path, RegistryKey* key) { + return ::RegCreateKeyExW(HKEY_CURRENT_USER, path, 0, nullptr, 0, + KEY_SET_VALUE, nullptr, &key->value, + nullptr) == ERROR_SUCCESS; +} + +void SetStringValue(HKEY key, const wchar_t* name, const std::wstring& value) { + ::RegSetValueExW( + key, name, 0, REG_SZ, reinterpret_cast(value.c_str()), + static_cast((value.size() + 1) * sizeof(wchar_t))); +} + +std::wstring ReadDefaultCommand() { + DWORD type = 0; + DWORD size = 0; + if (::RegGetValueW(HKEY_CLASSES_ROOT, kEffectiveProtocolCommandKeyPath, + nullptr, RRF_RT_REG_SZ, &type, nullptr, &size) != + ERROR_SUCCESS || + size == 0) { + return L""; + } + + std::wstring value(size / sizeof(wchar_t), L'\0'); + if (::RegGetValueW(HKEY_CLASSES_ROOT, kEffectiveProtocolCommandKeyPath, + nullptr, RRF_RT_REG_SZ, &type, value.data(), &size) != + ERROR_SUCCESS) { + return L""; + } + while (!value.empty() && value.back() == L'\0') { + value.pop_back(); + } + return value; +} + +void NotifyAssociationChanged() { + ::SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); +} + +} // namespace + +void RegisterZcashProtocolHandler() { + const std::wstring module_path = ModuleFileName(); + if (module_path.empty()) { + return; + } + + RegistryKey protocol_key; + if (!CreateCurrentUserKey(kProtocolKeyPath, &protocol_key)) { + return; + } + SetStringValue(protocol_key.value, nullptr, L"URL:Zcash Payment URI"); + SetStringValue(protocol_key.value, L"URL Protocol", L""); + + RegistryKey icon_key; + if (CreateCurrentUserKey(L"Software\\Classes\\zcash\\DefaultIcon", + &icon_key)) { + SetStringValue(icon_key.value, nullptr, L"\"" + module_path + L"\",0"); + } + + RegistryKey command_key; + if (!CreateCurrentUserKey(kProtocolCommandKeyPath, &command_key)) { + return; + } + SetStringValue(command_key.value, nullptr, + L"\"" + module_path + L"\" \"%1\""); + NotifyAssociationChanged(); +} + +void UnregisterZcashProtocolHandler() { + const std::wstring module_path = ToLower(ModuleFileName()); + const std::wstring command = ToLower(ReadDefaultCommand()); + if (module_path.empty() || command.find(module_path) == std::wstring::npos) { + return; + } + + ::RegDeleteTreeW(HKEY_CURRENT_USER, kProtocolKeyPath); + NotifyAssociationChanged(); +} + +void RegisterZcashProtocolHandlerIfUnclaimed() { + const std::wstring module_path = ToLower(ModuleFileName()); + if (module_path.empty()) { + return; + } + // Only (re)register at startup when no handler is set yet, or when the + // existing handler already points at this install. Registering on every + // launch unconditionally would silently steal the zcash: handler back from + // another wallet (or another Vizor channel) the user selected. Install and + // update hooks still register unconditionally -- that is the intended moment + // to claim the handler. + const std::wstring command = ToLower(ReadDefaultCommand()); + if (!command.empty() && command.find(module_path) == std::wstring::npos) { + return; + } + RegisterZcashProtocolHandler(); +} diff --git a/windows/runner/payment_uri_protocol.h b/windows/runner/payment_uri_protocol.h new file mode 100644 index 000000000..405f9cf1c --- /dev/null +++ b/windows/runner/payment_uri_protocol.h @@ -0,0 +1,12 @@ +#ifndef RUNNER_PAYMENT_URI_PROTOCOL_H_ +#define RUNNER_PAYMENT_URI_PROTOCOL_H_ + +void RegisterZcashProtocolHandler(); +// Like RegisterZcashProtocolHandler, but only registers when no handler owns +// the zcash: scheme yet, or when this exact install already owns it. Use this +// on normal startup so a launch does not steal the handler the user picked; +// the install/update hooks use the unconditional variant to claim it. +void RegisterZcashProtocolHandlerIfUnclaimed(); +void UnregisterZcashProtocolHandler(); + +#endif // RUNNER_PAYMENT_URI_PROTOCOL_H_ diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp index 5ff552dff..19a21dc20 100644 --- a/windows/runner/utils.cpp +++ b/windows/runner/utils.cpp @@ -5,8 +5,28 @@ #include #include +#include #include +bool IsZcashUri(const std::string& value) { + constexpr char prefix[] = "zcash:"; + constexpr size_t prefix_length = sizeof(prefix) - 1; + if (value.size() < prefix_length || value.size() > kMaxZcashUriBytes) { + return false; + } + + for (size_t i = 0; i < prefix_length; ++i) { + const auto actual = + static_cast(value[i]); + const auto expected = + static_cast(prefix[i]); + if (std::tolower(actual) != std::tolower(expected)) { + return false; + } + } + return true; +} + void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; @@ -41,6 +61,17 @@ std::vector GetCommandLineArguments() { return command_line_arguments; } +std::vector GetZcashUriArguments( + const std::vector& arguments) { + std::vector uris; + for (const auto& argument : arguments) { + if (IsZcashUri(argument)) { + uris.push_back(argument); + } + } + return uris; +} + std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); diff --git a/windows/runner/utils.h b/windows/runner/utils.h index 3a9b31243..4706b06b8 100644 --- a/windows/runner/utils.h +++ b/windows/runner/utils.h @@ -1,9 +1,12 @@ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ +#include #include #include +constexpr size_t kMaxZcashUriBytes = 16 * 1024; + // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); @@ -20,4 +23,12 @@ std::wstring Utf16FromUtf8(const std::string& utf8_string); // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); +// Extracts zcash: payment URIs from command-line arguments. +std::vector GetZcashUriArguments( + const std::vector& arguments); + +// Returns whether |value| is a zcash: URI small enough to forward through the +// native launch-URI bridge. +bool IsZcashUri(const std::string& value); + #endif // RUNNER_UTILS_H_ diff --git a/windows/runner/velopack_uninstall.cpp b/windows/runner/velopack_uninstall.cpp index e57a023e9..7a5baf8cf 100644 --- a/windows/runner/velopack_uninstall.cpp +++ b/windows/runner/velopack_uninstall.cpp @@ -1,5 +1,7 @@ #include "velopack_uninstall.h" +#include "payment_uri_protocol.h" + #include #include @@ -213,13 +215,22 @@ void DeleteUserData() { } void BeforeUninstallHook(void* user_data, const char* app_version) { + UnregisterZcashProtocolHandler(); DeleteUserData(); } +void RegisterProtocolHook(void* user_data, const char* app_version) { + RegisterZcashProtocolHandler(); +} + } // namespace void RunVelopackHooks() { vpkc_app_set_auto_apply_on_startup(false); + vpkc_app_set_hook_after_install(RegisterProtocolHook); + vpkc_app_set_hook_after_update(RegisterProtocolHook); vpkc_app_set_hook_before_uninstall(BeforeUninstallHook); + vpkc_app_set_hook_first_run(RegisterProtocolHook); + vpkc_app_set_hook_restarted(RegisterProtocolHook); vpkc_app_run(nullptr); }