Skip to content
Draft
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
42920cf
Add macOS ZIP-321 payment URI handling
piatoss3612 May 22, 2026
1303600
Add Windows and Linux ZIP-321 URI handling
piatoss3612 May 22, 2026
c11e2eb
Keep payment URI prefill during send route refresh
piatoss3612 May 22, 2026
7a24072
Add payment URI prefill integration test
piatoss3612 May 22, 2026
86b9408
Add Android and iOS ZIP-321 payment URI handling
piatoss3612 Jun 23, 2026
2f8c672
Deliver payment-URI prefill to /send after unlock, not /home
piatoss3612 Jun 23, 2026
5031e50
Add regtest E2E for zcash: payment-URI send
piatoss3612 Jun 23, 2026
8e12485
Add regtest E2E for the locked-path payment-URI send
piatoss3612 Jun 23, 2026
ab97ed0
Apply ZIP-321 payment-URI prefill on the mobile /send route
piatoss3612 Jun 23, 2026
389bad3
Harden the locked-path payment-URI prefill (review fixes)
piatoss3612 Jun 23, 2026
d3bfc62
Gate mobile amount-step jump on a valid address + cold-start test (re…
piatoss3612 Jun 23, 2026
55bc6ad
Don't steal the zcash: handler on every Windows launch (review)
piatoss3612 Jun 23, 2026
b177b5b
Consolidate ZIP-321 parser to one core copy (review)
piatoss3612 Jun 23, 2026
22a89c4
Fix mobile send back nav from a prefilled deep link
piatoss3612 Jun 23, 2026
8d56cf5
Fix mobile send back nav: page pop in normal flow, step fallback on d…
piatoss3612 Jun 23, 2026
124268e
Fix Windows zcash: handler compile error (SHChangeNotify + shlobj.h)
piatoss3612 Jun 27, 2026
8b8d354
core: Bound ZIP-321 memo decode size
piatoss3612 Jun 28, 2026
94d7eee
mobile: Gate ZIP-321 amount prefills on recipient validation
piatoss3612 Jun 28, 2026
6df3c59
desktop: Respect existing zcash URI handlers
piatoss3612 Jun 28, 2026
daf9b2d
core: Guard payment URI handoff during sends
piatoss3612 Jun 28, 2026
3b1c596
mobile: Ignore stale Android payment intents
piatoss3612 Jun 28, 2026
f0666fd
test: sync mobile send focused preview expectation
piatoss3612 Jun 29, 2026
4c4b000
fix(windows): forward payment URIs to running instance
piatoss3612 Jun 29, 2026
c36974e
fix(windows): present window for forwarded payment URIs
piatoss3612 Jun 29, 2026
0bef0cf
fix(android): disable Flutter deeplink dispatch for payment URIs
piatoss3612 Jun 29, 2026
e6d9c10
fix(macos): present window for payment URI links
piatoss3612 Jun 29, 2026
5bddd0d
fix(zip321): reject unsupported memo controls
piatoss3612 Jun 29, 2026
47e5528
fix(zip321): preserve uri memo whitespace
piatoss3612 Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,22 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- ZIP-321 payment links: zcash:<address>?amount=... open Vizor.
Handled in MainActivity (com.zcash.wallet/payment_uri channel),
not Flutter's built-in deep linking. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="zcash"/>
Comment thread
piatoss3612 marked this conversation as resolved.
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
52 changes: 52 additions & 0 deletions android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
private var paymentUriDartReady = false

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
23 changes: 23 additions & 0 deletions dev/open_payment_uri.sh
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions dev/payment_uri_demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vizor Payment URI Demo</title>
<style>
:root {
color-scheme: light dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}

body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: Canvas;
color: CanvasText;
}

main {
width: min(680px, calc(100vw - 48px));
}

a {
display: inline-flex;
min-height: 44px;
align-items: center;
border: 1px solid currentColor;
border-radius: 6px;
padding: 0 18px;
color: inherit;
font-weight: 700;
text-decoration: none;
}

code {
display: block;
margin-top: 18px;
overflow-wrap: anywhere;
line-height: 1.6;
}
</style>
</head>
<body>
<main>
<a href="zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=0.12345678&amp;memo=Q1AtQzZDREI3NzU&amp;message=Thank%20you%20for%20your%20purchase">
Open in Vizor
</a>

<code>
zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=0.12345678&amp;memo=Q1AtQzZDREI3NzU&amp;message=Thank%20you%20for%20your%20purchase
</code>
</main>
</body>
</html>
160 changes: 160 additions & 0 deletions integration_test/payment_uri_prefill_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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<EditableText>(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<SyncState> build() async => initialState;
}
Loading