From 7c543c32a00af600e1827091cdcfa92b23c1b371 Mon Sep 17 00:00:00 2001
From: JeffG <202880+erskingardner@users.noreply.github.com>
Date: Fri, 15 May 2026 12:34:58 +0200
Subject: [PATCH] Add native deep links for users, chats, and settings (#661)
* Add copy deep link action to chat debug screen
* Address deep link review feedback
* Address deep link PR review follow-ups
* Scale profile QR code size
---
android/app/build.gradle.kts | 2 +
android/app/src/main/AndroidManifest.xml | 6 +
ios/Runner.xcodeproj/project.pbxproj | 5 +
ios/Runner/Info.plist | 11 ++
lib/l10n/app_de.arb | 1 +
lib/l10n/app_en.arb | 4 +
lib/l10n/app_es.arb | 1 +
lib/l10n/app_fr.arb | 1 +
lib/l10n/app_it.arb | 1 +
lib/l10n/app_pt.arb | 1 +
lib/l10n/app_ru.arb | 1 +
lib/l10n/app_tr.arb | 1 +
lib/l10n/app_zh.arb | 1 +
lib/l10n/app_zh_Hant.arb | 1 +
lib/l10n/generated/app_localizations.dart | 6 +
lib/l10n/generated/app_localizations_de.dart | 3 +
lib/l10n/generated/app_localizations_en.dart | 3 +
lib/l10n/generated/app_localizations_es.dart | 3 +
lib/l10n/generated/app_localizations_fr.dart | 3 +
lib/l10n/generated/app_localizations_it.dart | 3 +
lib/l10n/generated/app_localizations_pt.dart | 3 +
lib/l10n/generated/app_localizations_ru.dart | 3 +
lib/l10n/generated/app_localizations_tr.dart | 3 +
lib/l10n/generated/app_localizations_zh.dart | 6 +
lib/providers/deep_link_provider.dart | 13 +++
lib/routes.dart | 37 +++++-
lib/screens/chat_raw_debug_screen.dart | 49 +++++++-
lib/screens/login_screen.dart | 2 +-
lib/screens/relay_resolution_screen.dart | 4 +-
lib/screens/scan_npub_screen.dart | 9 ++
lib/screens/share_profile_screen.dart | 49 ++++----
lib/utils/deep_links.dart | 109 ++++++++++++++++++
test/providers/deep_link_provider_test.dart | 25 ++++
test/routes_test.dart | 61 ++++++++++
test/screens/chat_raw_debug_screen_test.dart | 65 ++++++++++-
test/screens/login_screen_test.dart | 66 +++++++++++
.../screens/relay_resolution_screen_test.dart | 39 ++++++-
test/screens/scan_npub_screen_test.dart | 27 +++++
test/screens/share_profile_screen_test.dart | 40 ++++++-
.../scripts/release_automation_config_test.sh | 17 +++
test/utils/deep_links_test.dart | 99 ++++++++++++++++
41 files changed, 755 insertions(+), 29 deletions(-)
create mode 100644 lib/providers/deep_link_provider.dart
create mode 100644 lib/utils/deep_links.dart
create mode 100644 test/providers/deep_link_provider_test.dart
create mode 100644 test/utils/deep_links_test.dart
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b58ec44..fa41381 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -141,10 +141,12 @@ android {
create("staging") {
dimension = "environment"
applicationIdSuffix = ".staging"
+ manifestPlaceholders["deepLinkScheme"] = "whitenoise-staging"
resValue("string", "app_name", "WN Staging")
}
create("production") {
dimension = "environment"
+ manifestPlaceholders["deepLinkScheme"] = "whitenoise"
resValue("string", "app_name", "White Noise")
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b7f688d..27834cd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -34,6 +34,12 @@
+
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 0db02e0..282590a 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -523,6 +523,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEEPLINK_SCHEME = "whitenoise";
DEVELOPMENT_TEAM = QL6TB358UB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -713,6 +714,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEEPLINK_SCHEME = "whitenoise-staging";
DEVELOPMENT_TEAM = QL6TB358UB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -739,6 +741,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEEPLINK_SCHEME = "whitenoise";
DEVELOPMENT_TEAM = QL6TB358UB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -822,6 +825,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEEPLINK_SCHEME = "whitenoise";
DEVELOPMENT_TEAM = QL6TB358UB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -921,6 +925,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEEPLINK_SCHEME = "whitenoise-staging";
DEVELOPMENT_TEAM = QL6TB358UB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 05857c8..c85f4c4 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -20,6 +20,17 @@
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleURLSchemes
+
+ $(DEEPLINK_SCHEME)
+
+
+
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index df290f9..ee4ec68 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "Gruppen-ID",
"rawDebugViewMessageCount": "{count, plural, =1{1 Nachricht} other{{count} Nachrichten}}",
"rawDebugViewCopied": "In die Zwischenablage kopiert",
+ "rawDebugViewCopyDeepLink": "Deep-Link kopieren",
"appLogsTitle": "App-Logs",
"appLogsViewLogs": "Logs anzeigen",
"appLogsViewLogsDescription": "Alle Logger-Ausgaben in der App anzeigen",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 24f8ee5..5e90bcd 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1606,6 +1606,10 @@
"@rawDebugViewCopied": {
"description": "Feedback text after copying debug data"
},
+ "rawDebugViewCopyDeepLink": "Copy deep link",
+ "@rawDebugViewCopyDeepLink": {
+ "description": "Button label for copying a debug chat deep link"
+ },
"appLogsTitle": "App Logs",
"@appLogsTitle": {
"description": "Screen title for the in-app log viewer"
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index a4cab97..2c2da13 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID del Grupo",
"rawDebugViewMessageCount": "{count, plural, =1{1 mensaje} other{{count} mensajes}}",
"rawDebugViewCopied": "Copiado al portapapeles",
+ "rawDebugViewCopyDeepLink": "Copiar enlace profundo",
"appLogsTitle": "Registros de la aplicación",
"appLogsViewLogs": "Ver registros",
"appLogsViewLogsDescription": "Ver todas las salidas del Logger en la aplicación",
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 23a5cb9..cf1f66d 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID du Groupe",
"rawDebugViewMessageCount": "{count, plural, =1{1 message} other{{count} messages}}",
"rawDebugViewCopied": "Copié dans le presse-papiers",
+ "rawDebugViewCopyDeepLink": "Copier le lien profond",
"appLogsTitle": "Journaux de l'application",
"appLogsViewLogs": "Voir les journaux",
"appLogsViewLogsDescription": "Afficher toutes les sorties du journal de l'application",
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 9d117d5..77f5386 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID del Gruppo",
"rawDebugViewMessageCount": "{count, plural, =1{1 messaggio} other{{count} messaggi}}",
"rawDebugViewCopied": "Copiato negli appunti",
+ "rawDebugViewCopyDeepLink": "Copia link diretto",
"appLogsTitle": "Registri dell'app",
"appLogsViewLogs": "Visualizza registri",
"appLogsViewLogsDescription": "Visualizza tutti gli output del Logger nell'app",
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 6518445..ce41bfe 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID do Grupo",
"rawDebugViewMessageCount": "{count, plural, =1{1 mensagem} other{{count} mensagens}}",
"rawDebugViewCopied": "Copiado para a área de transferência",
+ "rawDebugViewCopyDeepLink": "Copiar link profundo",
"appLogsTitle": "Registros do App",
"appLogsViewLogs": "Ver registros",
"appLogsViewLogsDescription": "Ver todas as saídas do Logger no app",
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 96d7244..39c6ba8 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID группы",
"rawDebugViewMessageCount": "{count, plural, =1{1 сообщение} few{{count} сообщения} many{{count} сообщений} other{{count} сообщений}}",
"rawDebugViewCopied": "Скопировано в буфер обмена",
+ "rawDebugViewCopyDeepLink": "Скопировать диплинк",
"appLogsTitle": "Журналы приложения",
"appLogsViewLogs": "Просмотреть логи",
"appLogsViewLogsDescription": "Просмотреть все записи Logger в приложении",
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index 524c820..8c13594 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "Grup Kimliği",
"rawDebugViewMessageCount": "{count, plural, =1{1 mesaj} other{{count} mesaj}}",
"rawDebugViewCopied": "Panoya kopyalandı",
+ "rawDebugViewCopyDeepLink": "Derin bağlantıyı kopyala",
"appLogsTitle": "Uygulama Günlükleri",
"appLogsViewLogs": "Günlüklere bak",
"appLogsViewLogsDescription": "Uygulamadaki tüm Logger çıktısını görüntüle",
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 2a5e1d9..614e4ba 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -366,6 +366,7 @@
"rawDebugViewGroupId": "群组 ID",
"rawDebugViewMessageCount": "{count, plural, =0{无消息} =1{1 条消息} other{{count} 条消息}}",
"rawDebugViewCopied": "已复制到剪贴板",
+ "rawDebugViewCopyDeepLink": "复制深层链接",
"appLogsTitle": "应用日志",
"appLogsViewLogs": "查看日志",
"appLogsViewLogsDescription": "在应用内查看所有日志输出",
diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb
index a5e77db..fc74a74 100644
--- a/lib/l10n/app_zh_Hant.arb
+++ b/lib/l10n/app_zh_Hant.arb
@@ -366,6 +366,7 @@
"rawDebugViewGroupId": "群組 ID",
"rawDebugViewMessageCount": "{count, plural, =0{沒有訊息} =1{1 則訊息} other{{count} 則訊息}}",
"rawDebugViewCopied": "已複製到剪貼簿",
+ "rawDebugViewCopyDeepLink": "複製深層連結",
"appLogsTitle": "應用程式記錄",
"appLogsViewLogs": "查看記錄",
"appLogsViewLogsDescription": "在應用程式內查看所有 Logger 輸出",
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 6e66a16..6da92f0 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -2307,6 +2307,12 @@ abstract class AppLocalizations {
/// **'Copied to clipboard'**
String get rawDebugViewCopied;
+ /// Button label for copying a debug chat deep link
+ ///
+ /// In en, this message translates to:
+ /// **'Copy deep link'**
+ String get rawDebugViewCopyDeepLink;
+
/// Screen title for the in-app log viewer
///
/// In en, this message translates to:
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 5656f07..ebe8562 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -1301,6 +1301,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get rawDebugViewCopied => 'In die Zwischenablage kopiert';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Deep-Link kopieren';
+
@override
String get appLogsTitle => 'App-Logs';
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index 97212b2..813abed 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -1264,6 +1264,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copied to clipboard';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Copy deep link';
+
@override
String get appLogsTitle => 'App Logs';
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index c2ec40b..64ee65f 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -1282,6 +1282,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiado al portapapeles';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Copiar enlace profundo';
+
@override
String get appLogsTitle => 'Registros de la aplicación';
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index 541ae19..3a9b13a 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -1280,6 +1280,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copié dans le presse-papiers';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Copier le lien profond';
+
@override
String get appLogsTitle => 'Journaux de l\'application';
diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart
index e0a799e..66fa36b 100644
--- a/lib/l10n/generated/app_localizations_it.dart
+++ b/lib/l10n/generated/app_localizations_it.dart
@@ -1268,6 +1268,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiato negli appunti';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Copia link diretto';
+
@override
String get appLogsTitle => 'Registri dell\'app';
diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart
index 0304dfa..6d14093 100644
--- a/lib/l10n/generated/app_localizations_pt.dart
+++ b/lib/l10n/generated/app_localizations_pt.dart
@@ -1278,6 +1278,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiado para a área de transferência';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Copiar link profundo';
+
@override
String get appLogsTitle => 'Registros do App';
diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart
index 61103cd..d1a0ff7 100644
--- a/lib/l10n/generated/app_localizations_ru.dart
+++ b/lib/l10n/generated/app_localizations_ru.dart
@@ -1298,6 +1298,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Скопировано в буфер обмена';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Скопировать диплинк';
+
@override
String get appLogsTitle => 'Журналы приложения';
diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart
index 8b5264a..713ec5c 100644
--- a/lib/l10n/generated/app_localizations_tr.dart
+++ b/lib/l10n/generated/app_localizations_tr.dart
@@ -1265,6 +1265,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Panoya kopyalandı';
+ @override
+ String get rawDebugViewCopyDeepLink => 'Derin bağlantıyı kopyala';
+
@override
String get appLogsTitle => 'Uygulama Günlükleri';
diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart
index 7abd975..f0caf11 100644
--- a/lib/l10n/generated/app_localizations_zh.dart
+++ b/lib/l10n/generated/app_localizations_zh.dart
@@ -1227,6 +1227,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get rawDebugViewCopied => '已复制到剪贴板';
+ @override
+ String get rawDebugViewCopyDeepLink => '复制深层链接';
+
@override
String get appLogsTitle => '应用日志';
@@ -2640,6 +2643,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get rawDebugViewCopied => '已複製到剪貼簿';
+ @override
+ String get rawDebugViewCopyDeepLink => '複製深層連結';
+
@override
String get appLogsTitle => '應用程式記錄';
diff --git a/lib/providers/deep_link_provider.dart b/lib/providers/deep_link_provider.dart
new file mode 100644
index 0000000..9cbe73b
--- /dev/null
+++ b/lib/providers/deep_link_provider.dart
@@ -0,0 +1,13 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:whitenoise/utils/deep_links.dart';
+
+final deepLinkSchemeProvider = FutureProvider((ref) async {
+ final info = await PackageInfo.fromPlatform();
+ return deepLinkSchemeForPackageName(info.packageName);
+});
+
+String deepLinkSchemeForPackageName(String packageName) {
+ if (packageName.endsWith('.staging')) return DeepLinks.stagingScheme;
+ return DeepLinks.productionScheme;
+}
diff --git a/lib/routes.dart b/lib/routes.dart
index e674372..44509b7 100644
--- a/lib/routes.dart
+++ b/lib/routes.dart
@@ -50,6 +50,7 @@ import 'package:whitenoise/screens/switch_profile_screen.dart' show SwitchProfil
import 'package:whitenoise/screens/user_search_screen.dart' show UserSearchScreen;
import 'package:whitenoise/screens/user_selection_screen.dart' show UserSelectionScreen;
import 'package:whitenoise/src/rust/api/users.dart' show User;
+import 'package:whitenoise/utils/deep_links.dart' show DeepLinks;
import 'package:whitenoise/widgets/wn_slate_content_transition.dart' show WnSlateContentTransition;
final class AddRelayArgs {
@@ -116,6 +117,12 @@ abstract final class Routes {
observers: [routeObserver, ActiveChatRouteObserver(ref.read(activeChatProvider.notifier))],
redirect: (context, state) {
final pubkey = ref.read(authProvider).value;
+ final deepLinkLocation = DeepLinks.parse(state.uri)?.location;
+ if (deepLinkLocation != null) {
+ if (pubkey == null) return _loginLocation(redirect: deepLinkLocation);
+ return deepLinkLocation;
+ }
+
final isOnPublicPage = _publicRoutes.contains(state.matchedLocation);
final isAddingAccount = ref.read(isAddingAccountProvider);
@@ -498,6 +505,11 @@ abstract final class Routes {
GoRouter.of(context).go(_login);
}
+ static void goToPostAuthDestination(BuildContext context) {
+ final redirect = _safeRedirect(GoRouterState.of(context).uri.queryParameters['redirect']);
+ GoRouter.of(context).go(redirect ?? _chatList);
+ }
+
static void pushToLogin(BuildContext context) {
GoRouter.of(context).push(_login);
}
@@ -630,8 +642,9 @@ abstract final class Routes {
required String pubkey,
required bool isExternalSigner,
}) {
+ final redirect = _safeRedirect(GoRouterState.of(context).uri.queryParameters['redirect']);
GoRouter.of(context).push(
- _relayResolution,
+ _relayResolutionLocation(redirect: redirect),
extra: RelayResolutionArgs(pubkey: pubkey, isExternalSigner: isExternalSigner),
);
}
@@ -698,4 +711,26 @@ abstract final class Routes {
static void pushToChatRawDebug(BuildContext context, String groupId) {
GoRouter.of(context).pushNamed('chatRawDebug', pathParameters: {'groupId': groupId});
}
+
+ static String _loginLocation({String? redirect}) {
+ if (redirect == null) return _login;
+ return Uri(path: _login, queryParameters: {'redirect': redirect}).toString();
+ }
+
+ static String _relayResolutionLocation({String? redirect}) {
+ if (redirect == null) return _relayResolution;
+ return Uri(path: _relayResolution, queryParameters: {'redirect': redirect}).toString();
+ }
+
+ static String? _safeRedirect(String? location) {
+ if (location == null || location.isEmpty) return null;
+
+ final uri = Uri.tryParse(location);
+ if (uri == null) return null;
+ if (uri.hasScheme || uri.hasAuthority) return null;
+ if (!uri.path.startsWith('/')) return null;
+ if (_publicRoutes.contains(uri.path)) return null;
+
+ return location;
+ }
}
diff --git a/lib/screens/chat_raw_debug_screen.dart b/lib/screens/chat_raw_debug_screen.dart
index 827de2c..437cef7 100644
--- a/lib/screens/chat_raw_debug_screen.dart
+++ b/lib/screens/chat_raw_debug_screen.dart
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whitenoise/hooks/use_chat_messages.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
+import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/providers/message_debug_log_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
@@ -15,8 +16,10 @@ import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/theme.dart';
+import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/widgets/debug_key_value_row.dart';
import 'package:whitenoise/widgets/debug_section_card.dart';
+import 'package:whitenoise/widgets/wn_icon.dart';
import 'package:whitenoise/widgets/wn_pill.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
import 'package:whitenoise/widgets/wn_slate_navigation_header.dart';
@@ -153,7 +156,7 @@ class ChatRawDebugScreen extends HookConsumerWidget {
}
}
-class _DebugHeader extends StatelessWidget {
+class _DebugHeader extends ConsumerWidget {
const _DebugHeader({
required this.groupId,
required this.messageCount,
@@ -167,7 +170,13 @@ class _DebugHeader extends StatelessWidget {
final String? latestMessagePubkey;
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final colors = context.colors;
+ final typography = context.typographyScaled;
+ final deepLinkSchemeState = ref.watch(deepLinkSchemeProvider);
+ final chatDeepLink = deepLinkSchemeState.hasValue
+ ? DeepLinks.chatUri(groupId, scheme: deepLinkSchemeState.value!)
+ : null;
final copyText = [
'group_id: $groupId',
'message_count: $messageCount',
@@ -187,6 +196,42 @@ class _DebugHeader extends StatelessWidget {
value: groupId,
valueKey: const Key('debug_group_id'),
),
+ if (chatDeepLink != null) ...[
+ SizedBox(height: 8.h),
+ Material(
+ color: Colors.transparent,
+ child: InkWell(
+ key: const Key('debug_chat_deep_link_copy_button'),
+ borderRadius: BorderRadius.circular(8.r),
+ onTap: () => _copyDebugText(context, chatDeepLink),
+ child: Ink(
+ padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h),
+ decoration: BoxDecoration(
+ color: colors.fillSecondary,
+ borderRadius: BorderRadius.circular(8.r),
+ border: Border.all(color: colors.borderTertiary),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ WnIcon(
+ WnIcons.copy,
+ size: 14.w,
+ color: colors.backgroundContentSecondary,
+ ),
+ SizedBox(width: 6.w),
+ Text(
+ context.l10n.rawDebugViewCopyDeepLink,
+ style: typography.medium10.copyWith(
+ color: colors.backgroundContentSecondary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
SizedBox(height: 4.h),
DebugKeyValueRow(
label: 'message_count',
diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart
index 14b0f4d..b13893a 100644
--- a/lib/screens/login_screen.dart
+++ b/lib/screens/login_screen.dart
@@ -74,7 +74,7 @@ void _handleLoginResult(
if (!context.mounted) return;
if (result.status == LoginStatus.complete) {
- Routes.goToChatList(context);
+ Routes.goToPostAuthDestination(context);
} else if (result.status == LoginStatus.needsRelayLists) {
Routes.pushToRelayResolution(
context,
diff --git a/lib/screens/relay_resolution_screen.dart b/lib/screens/relay_resolution_screen.dart
index c3d2adc..b2e8d1c 100644
--- a/lib/screens/relay_resolution_screen.dart
+++ b/lib/screens/relay_resolution_screen.dart
@@ -69,14 +69,14 @@ class RelayResolutionScreen extends HookConsumerWidget {
Future onPublishDefaults() async {
final success = await publishDefaults();
if (success && context.mounted) {
- Routes.goToChatList(context);
+ Routes.goToPostAuthDestination(context);
}
}
Future onTryCustomRelay() async {
final success = await tryCustomRelay();
if (success && context.mounted) {
- Routes.goToChatList(context);
+ Routes.goToPostAuthDestination(context);
}
}
diff --git a/lib/screens/scan_npub_screen.dart b/lib/screens/scan_npub_screen.dart
index 066995b..e59f3d4 100644
--- a/lib/screens/scan_npub_screen.dart
+++ b/lib/screens/scan_npub_screen.dart
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart' show Gap;
+import 'package:go_router/go_router.dart' show GoRouter;
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/routes.dart' show Routes;
import 'package:whitenoise/theme.dart';
+import 'package:whitenoise/utils/deep_links.dart' show DeepLinks;
import 'package:whitenoise/utils/encoding.dart' show hexFromNpub;
import 'package:whitenoise/widgets/qr_scanner.dart' show QrScanner;
import 'package:whitenoise/widgets/wn_slate.dart';
@@ -20,6 +22,13 @@ class ScanNpubScreen extends HookWidget {
final showInvalidNpubError = useState(false);
void onBarcodeDetected(String value) {
+ final deepLinkTarget = DeepLinks.parseString(value);
+ if (deepLinkTarget != null) {
+ Routes.goBack(context);
+ GoRouter.of(context).push(deepLinkTarget.location);
+ return;
+ }
+
final hexPubkey = hexFromNpub(value);
if (hexPubkey != null) {
Routes.goBack(context);
diff --git a/lib/screens/share_profile_screen.dart b/lib/screens/share_profile_screen.dart
index 4b41fc6..e36633a 100644
--- a/lib/screens/share_profile_screen.dart
+++ b/lib/screens/share_profile_screen.dart
@@ -7,8 +7,10 @@ import 'package:whitenoise/hooks/use_system_notice.dart';
import 'package:whitenoise/hooks/use_user_metadata.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
+import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/theme.dart';
+import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/utils/formatting.dart';
import 'package:whitenoise/utils/metadata.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
@@ -29,6 +31,10 @@ class ShareProfileScreen extends HookConsumerWidget {
final pubkey = ref.watch(accountPubkeyProvider);
final metadataSnapshot = useUserMetadata(context, pubkey);
final npub = npubFromHex(pubkey);
+ final deepLinkSchemeState = ref.watch(deepLinkSchemeProvider);
+ final profileDeepLink = npub != null && deepLinkSchemeState.hasValue
+ ? DeepLinks.userUri(npub, scheme: deepLinkSchemeState.value!)
+ : null;
final (:noticeMessage, :noticeType, :showSuccessNotice, :showErrorNotice, :dismissNotice) =
useSystemNotice();
@@ -84,30 +90,33 @@ class ShareProfileScreen extends HookConsumerWidget {
snapToWords: true,
),
),
- Gap(36.h),
- ClipRRect(
- borderRadius: BorderRadius.circular(12.r),
- child: SizedBox.square(
- dimension: 256.w,
- child: FittedBox(
- fit: BoxFit.fill,
- child: QrImageView(
- data: npub,
- size: 98,
- padding: EdgeInsets.zero,
- backgroundColor: colors.backgroundSecondary,
- eyeStyle: QrEyeStyle(
- eyeShape: QrEyeShape.square,
- color: colors.backgroundContentPrimary,
- ),
- dataModuleStyle: QrDataModuleStyle(
- dataModuleShape: QrDataModuleShape.square,
- color: colors.backgroundContentPrimary,
+ if (profileDeepLink != null) ...[
+ Gap(36.h),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(12.r),
+ child: SizedBox.square(
+ dimension: 256.w,
+ child: FittedBox(
+ fit: BoxFit.fill,
+ child: QrImageView(
+ key: ValueKey(profileDeepLink),
+ data: profileDeepLink,
+ size: 98.w,
+ padding: EdgeInsets.zero,
+ backgroundColor: colors.backgroundSecondary,
+ eyeStyle: QrEyeStyle(
+ eyeShape: QrEyeShape.square,
+ color: colors.backgroundContentPrimary,
+ ),
+ dataModuleStyle: QrDataModuleStyle(
+ dataModuleShape: QrDataModuleShape.square,
+ color: colors.backgroundContentPrimary,
+ ),
),
),
),
),
- ),
+ ],
] else
Gap(32.h),
Gap(12.h),
diff --git a/lib/utils/deep_links.dart b/lib/utils/deep_links.dart
new file mode 100644
index 0000000..4d0663c
--- /dev/null
+++ b/lib/utils/deep_links.dart
@@ -0,0 +1,109 @@
+import 'package:whitenoise/utils/encoding.dart' show hexFromNpub;
+
+enum DeepLinkTargetType { user, chat, settings }
+
+class DeepLinkTarget {
+ const DeepLinkTarget({
+ required this.type,
+ required this.location,
+ });
+
+ final DeepLinkTargetType type;
+ final String location;
+}
+
+abstract final class DeepLinks {
+ static const productionScheme = 'whitenoise';
+ static const stagingScheme = 'whitenoise-staging';
+
+ static const _settingsLocations = {
+ 'settings': '/settings',
+ 'settings/share-profile': '/share-profile',
+ 'settings/switch-profile': '/switch-profile',
+ 'settings/edit-profile': '/edit-profile',
+ 'settings/profile-keys': '/profile-keys',
+ 'settings/network': '/network',
+ 'settings/privacy-security': '/privacy-security',
+ 'settings/appearance': '/appearance',
+ 'settings/notifications': '/notification-settings',
+ 'settings/report-bug': '/report-bug',
+ 'settings/donate': '/donate',
+ 'settings/developer': '/developer-settings',
+ 'settings/developer/key-packages': '/key-package-management',
+ 'settings/developer/relay-state': '/relay-control-state',
+ 'settings/developer/app-logs': '/app-logs',
+ };
+
+ static String userUri(String npub, {String scheme = productionScheme}) {
+ return Uri(scheme: scheme, host: 'user', pathSegments: [npub]).toString();
+ }
+
+ static String chatUri(String groupId, {String scheme = productionScheme}) {
+ return Uri(scheme: scheme, host: 'chat', pathSegments: [groupId]).toString();
+ }
+
+ static DeepLinkTarget? parse(Uri uri) {
+ if (!_isSupportedScheme(uri.scheme)) return null;
+
+ final segments = _segments(uri);
+ if (segments.isEmpty) return null;
+
+ return switch (segments.first.toLowerCase()) {
+ 'user' => _parseUser(segments),
+ 'chat' => _parseChat(segments),
+ 'settings' => _parseSettings(segments),
+ _ => null,
+ };
+ }
+
+ static DeepLinkTarget? parseString(String value) {
+ final uri = Uri.tryParse(value.trim());
+ if (uri == null) return null;
+ return parse(uri);
+ }
+
+ static bool _isSupportedScheme(String scheme) {
+ return scheme == productionScheme || scheme == stagingScheme;
+ }
+
+ static List _segments(Uri uri) {
+ return [
+ if (uri.host.isNotEmpty) uri.host,
+ ...uri.pathSegments,
+ ].where((segment) => segment.isNotEmpty).toList(growable: false);
+ }
+
+ static DeepLinkTarget? _parseUser(List segments) {
+ if (segments.length != 2) return null;
+
+ final pubkey = hexFromNpub(segments[1]);
+ if (pubkey == null) return null;
+
+ return DeepLinkTarget(
+ type: DeepLinkTargetType.user,
+ location: '/start-chat/${Uri.encodeComponent(pubkey)}',
+ );
+ }
+
+ static DeepLinkTarget? _parseChat(List segments) {
+ if (segments.length != 2) return null;
+
+ final groupId = segments[1];
+ if (groupId.isEmpty) return null;
+
+ return DeepLinkTarget(
+ type: DeepLinkTargetType.chat,
+ location: '/chats/${Uri.encodeComponent(groupId)}',
+ );
+ }
+
+ static DeepLinkTarget? _parseSettings(List segments) {
+ final location = _settingsLocations[segments.join('/')];
+ if (location == null) return null;
+
+ return DeepLinkTarget(
+ type: DeepLinkTargetType.settings,
+ location: location,
+ );
+ }
+}
diff --git a/test/providers/deep_link_provider_test.dart b/test/providers/deep_link_provider_test.dart
new file mode 100644
index 0000000..37641cf
--- /dev/null
+++ b/test/providers/deep_link_provider_test.dart
@@ -0,0 +1,25 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:whitenoise/providers/deep_link_provider.dart';
+import 'package:whitenoise/utils/deep_links.dart';
+
+void main() {
+ group('deepLinkSchemeForPackageName', () {
+ test('returns production scheme for production package names', () {
+ expect(
+ deepLinkSchemeForPackageName('org.parres.whitenoise'),
+ DeepLinks.productionScheme,
+ );
+ });
+
+ test('returns staging scheme for staging package names', () {
+ expect(
+ deepLinkSchemeForPackageName('org.parres.whitenoise.staging'),
+ DeepLinks.stagingScheme,
+ );
+ expect(
+ deepLinkSchemeForPackageName('dev.ipf.whitenoise.staging'),
+ DeepLinks.stagingScheme,
+ );
+ });
+ });
+}
diff --git a/test/routes_test.dart b/test/routes_test.dart
index 82af4a8..65408d1 100644
--- a/test/routes_test.dart
+++ b/test/routes_test.dart
@@ -18,6 +18,7 @@ import 'package:whitenoise/screens/login_screen.dart';
import 'package:whitenoise/screens/notification_settings_screen.dart';
import 'package:whitenoise/screens/settings_screen.dart';
import 'package:whitenoise/screens/signup_screen.dart';
+import 'package:whitenoise/screens/start_chat_screen.dart';
import 'package:whitenoise/screens/start_support_chat_screen.dart';
import 'package:whitenoise/screens/user_search_screen.dart';
import 'package:whitenoise/screens/user_selection_screen.dart';
@@ -184,6 +185,66 @@ void main() {
});
});
+ group('deep links', () {
+ testWidgets('opens user links on the user profile screen', (tester) async {
+ await pumpRouter(
+ tester,
+ overrides: [
+ authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
+ ],
+ );
+
+ router.go('whitenoise://user/$testNpubB');
+ await tester.pumpAndSettle();
+
+ final screen = tester.widget(find.byType(StartChatScreen));
+ expect(screen.userPubkey, testPubkeyB);
+ });
+
+ testWidgets('opens chat links on the chat screen', (tester) async {
+ await pumpRouter(
+ tester,
+ overrides: [
+ authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
+ ],
+ );
+
+ router.go('whitenoise://chat/$testGroupId');
+ await tester.pumpAndSettle();
+
+ final screen = tester.widget(find.byType(ChatScreen));
+ expect(screen.groupId, testGroupId);
+ });
+
+ testWidgets('opens settings links on the target settings screen', (tester) async {
+ await pumpRouter(
+ tester,
+ overrides: [
+ authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
+ ],
+ );
+
+ router.go('whitenoise://settings/notifications');
+ await tester.pumpAndSettle();
+
+ expect(find.byType(NotificationSettingsScreen), findsOneWidget);
+ });
+
+ testWidgets('preserves protected deep-link target while redirecting to login', (tester) async {
+ await pumpRouter(tester);
+
+ router.go('whitenoise://chat/$testGroupId');
+ await tester.pumpAndSettle();
+
+ final context = tester.element(find.byType(LoginScreen));
+ expect(find.byType(LoginScreen), findsOneWidget);
+ expect(
+ GoRouterState.of(context).uri.queryParameters['redirect'],
+ '/chats/$testGroupId',
+ );
+ });
+ });
+
group('goBack', () {
testWidgets('navigates to previous route', (tester) async {
await pumpRouter(tester);
diff --git a/test/screens/chat_raw_debug_screen_test.dart b/test/screens/chat_raw_debug_screen_test.dart
index 185894a..42690ec 100644
--- a/test/screens/chat_raw_debug_screen_test.dart
+++ b/test/screens/chat_raw_debug_screen_test.dart
@@ -4,13 +4,17 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
+import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/providers/message_debug_log_provider.dart';
import 'package:whitenoise/screens/chat_raw_debug_screen.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
+import 'package:whitenoise/utils/deep_links.dart';
+import 'package:whitenoise/widgets/wn_icon.dart';
+import '../mocks/mock_clipboard.dart' show clearClipboardMock, mockClipboard;
import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';
@@ -119,11 +123,18 @@ void main() {
Future pumpDebugScreen(
WidgetTester tester, {
List overrides = const [],
+ Future Function()? deepLinkScheme,
}) async {
await mountWidget(
const ChatRawDebugScreen(groupId: _testGroupId),
tester,
- overrides: [accountPubkeyProvider.overrideWith(_MockAccountPubkeyNotifier.new), ...overrides],
+ overrides: [
+ accountPubkeyProvider.overrideWith(_MockAccountPubkeyNotifier.new),
+ deepLinkSchemeProvider.overrideWith(
+ (ref) => deepLinkScheme?.call() ?? Future.value(DeepLinks.productionScheme),
+ ),
+ ...overrides,
+ ],
);
await tester.pumpAndSettle();
}
@@ -141,6 +152,58 @@ void main() {
expect(find.text(_testGroupId), findsOneWidget);
});
+ testWidgets('copy deep link button uses localized label and app icon', (tester) async {
+ await pumpDebugScreen(tester);
+
+ final button = find.byKey(const Key('debug_chat_deep_link_copy_button'));
+ final icon = find.descendant(of: button, matching: find.byType(WnIcon));
+ expect(find.text('Copy deep link'), findsOneWidget);
+ expect(tester.widget(icon).icon, WnIcons.copy);
+ });
+
+ testWidgets('hides copy deep link button until deep link scheme is available', (
+ tester,
+ ) async {
+ final schemeCompleter = Completer();
+
+ await pumpDebugScreen(
+ tester,
+ deepLinkScheme: () => schemeCompleter.future,
+ );
+
+ expect(find.byKey(const Key('debug_chat_deep_link_copy_button')), findsNothing);
+ });
+
+ testWidgets('copy deep link button copies chat deep link', (tester) async {
+ final getClipboard = mockClipboard();
+ addTearDown(clearClipboardMock);
+
+ await pumpDebugScreen(tester);
+
+ await tester.tap(find.byKey(const Key('debug_chat_deep_link_copy_button')));
+ await tester.pumpAndSettle();
+
+ expect(getClipboard(), 'whitenoise://chat/$_testGroupId');
+ expect(find.text('Copied to clipboard'), findsOneWidget);
+ });
+
+ testWidgets('copy deep link button uses staging scheme when staging app is installed', (
+ tester,
+ ) async {
+ final getClipboard = mockClipboard();
+ addTearDown(clearClipboardMock);
+
+ await pumpDebugScreen(
+ tester,
+ deepLinkScheme: () async => DeepLinks.stagingScheme,
+ );
+
+ await tester.tap(find.byKey(const Key('debug_chat_deep_link_copy_button')));
+ await tester.pumpAndSettle();
+
+ expect(getClipboard(), 'whitenoise-staging://chat/$_testGroupId');
+ });
+
testWidgets('displays message count', (tester) async {
await pumpDebugScreen(tester);
diff --git a/test/screens/login_screen_test.dart b/test/screens/login_screen_test.dart
index 22d9f08..1386e2c 100644
--- a/test/screens/login_screen_test.dart
+++ b/test/screens/login_screen_test.dart
@@ -6,11 +6,13 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData, ProviderScope;
import 'package:flutter_screenutil/flutter_screenutil.dart' show ScreenUtilInit;
import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
import 'package:whitenoise/l10n/generated/app_localizations.dart';
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/providers/offline_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/screens/chat_list_screen.dart';
+import 'package:whitenoise/screens/chat_screen.dart';
import 'package:whitenoise/screens/home_screen.dart';
import 'package:whitenoise/screens/login_screen.dart';
import 'package:whitenoise/screens/relay_resolution_screen.dart';
@@ -200,6 +202,46 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(ChatListScreen), findsOneWidget);
});
+
+ testWidgets('redirects to preserved deep-link target on success', (tester) async {
+ await pumpLoginScreen(tester);
+ GoRouter.of(
+ tester.element(find.byType(LoginScreen)),
+ ).go('/login?redirect=${Uri.encodeComponent('/chats/$testGroupId')}');
+ await tester.pumpAndSettle();
+
+ await tester.enterText(find.byType(TextField), 'nsec1test');
+ await tester.pump();
+ await tester.tap(find.byKey(const Key('login_button')));
+ await tester.pumpAndSettle();
+
+ final screen = tester.widget(find.byType(ChatScreen));
+ expect(screen.groupId, testGroupId);
+ });
+
+ for (final (:label, :redirect) in [
+ (label: 'blank', redirect: ''),
+ (label: 'absolute URI', redirect: 'https://example.com/chats/$testGroupId'),
+ (label: 'authority URI', redirect: '//example.com/chats/$testGroupId'),
+ (label: 'relative path', redirect: 'chats/$testGroupId'),
+ (label: 'public route', redirect: '/login'),
+ ]) {
+ testWidgets('falls back to chat list for $label redirect on success', (tester) async {
+ await pumpLoginScreen(tester);
+ GoRouter.of(
+ tester.element(find.byType(LoginScreen)),
+ ).go('/login?redirect=${Uri.encodeComponent(redirect)}');
+ await tester.pumpAndSettle();
+
+ await tester.enterText(find.byType(TextField), 'nsec1test');
+ await tester.pump();
+ await tester.tap(find.byKey(const Key('login_button')));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ChatListScreen), findsOneWidget);
+ expect(find.byType(ChatScreen), findsNothing);
+ });
+ }
});
group('when login needs relay lists', () {
@@ -212,6 +254,30 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(RelayResolutionScreen), findsOneWidget);
});
+
+ testWidgets('preserves deep-link redirect when navigating to relay resolution', (
+ tester,
+ ) async {
+ await pumpLoginScreen(tester);
+ GoRouter.of(
+ tester.element(find.byType(LoginScreen)),
+ ).go('/login?redirect=${Uri.encodeComponent('/chats/$testGroupId')}');
+ await tester.pumpAndSettle();
+
+ mockAuth.loginResultStatus = LoginStatus.needsRelayLists;
+ await tester.enterText(find.byType(TextField), 'nsec1test');
+ await tester.pump();
+ await tester.tap(find.byKey(const Key('login_button')));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(RelayResolutionScreen), findsOneWidget);
+ expect(
+ GoRouterState.of(
+ tester.element(find.byType(RelayResolutionScreen)),
+ ).uri.queryParameters['redirect'],
+ '/chats/$testGroupId',
+ );
+ });
});
group('when login fails', () {
diff --git a/test/screens/relay_resolution_screen_test.dart b/test/screens/relay_resolution_screen_test.dart
index 492cb24..eef84d8 100644
--- a/test/screens/relay_resolution_screen_test.dart
+++ b/test/screens/relay_resolution_screen_test.dart
@@ -144,6 +144,7 @@ void main() {
WidgetTester tester, {
bool isExternalSigner = false,
bool useRouter = false,
+ String initialLocation = '/relay-resolution',
MediaQueryData? mediaQueryData,
}) async {
mockAuth = _MockAuthNotifier();
@@ -151,7 +152,7 @@ void main() {
if (useRouter) {
final router = GoRouter(
- initialLocation: '/relay-resolution',
+ initialLocation: initialLocation,
routes: [
GoRoute(
path: '/',
@@ -172,6 +173,12 @@ void main() {
body: Text('Chat List'),
),
),
+ GoRoute(
+ path: '/chats/:groupId',
+ builder: (context, state) => Scaffold(
+ body: Text('Chat ${state.pathParameters['groupId']}'),
+ ),
+ ),
],
);
@@ -482,6 +489,20 @@ void main() {
expect(find.text('Chat List'), findsOneWidget);
});
+ testWidgets('navigates to preserved redirect when use default relays succeeds', (
+ tester,
+ ) async {
+ await pumpRelayResolutionScreen(
+ tester,
+ useRouter: true,
+ initialLocation:
+ '/relay-resolution?redirect=${Uri.encodeComponent('/chats/$testGroupId')}',
+ );
+ await tester.tap(find.byKey(const Key('use_default_relays_button')));
+ await tester.pumpAndSettle();
+ expect(find.text('Chat $testGroupId'), findsOneWidget);
+ });
+
testWidgets('navigates to chat list when try custom relay succeeds', (tester) async {
await pumpRelayResolutionScreen(tester, useRouter: true);
await tester.enterText(find.byType(TextField), 'wss://relay.example.com');
@@ -490,6 +511,22 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Chat List'), findsOneWidget);
});
+
+ testWidgets('navigates to preserved redirect when try custom relay succeeds', (
+ tester,
+ ) async {
+ await pumpRelayResolutionScreen(
+ tester,
+ useRouter: true,
+ initialLocation:
+ '/relay-resolution?redirect=${Uri.encodeComponent('/chats/$testGroupId')}',
+ );
+ await tester.enterText(find.byType(TextField), 'wss://relay.example.com');
+ await tester.pump(const Duration(milliseconds: 600));
+ await tester.tap(find.byKey(const Key('try_custom_relay_button')));
+ await tester.pumpAndSettle();
+ expect(find.text('Chat $testGroupId'), findsOneWidget);
+ });
});
group('external signer', () {
diff --git a/test/screens/scan_npub_screen_test.dart b/test/screens/scan_npub_screen_test.dart
index 14143bf..f3a528d 100644
--- a/test/screens/scan_npub_screen_test.dart
+++ b/test/screens/scan_npub_screen_test.dart
@@ -5,6 +5,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/routes.dart';
+import 'package:whitenoise/screens/chat_screen.dart';
import 'package:whitenoise/screens/share_profile_screen.dart';
import 'package:whitenoise/screens/start_chat_screen.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
@@ -122,6 +123,32 @@ void main() {
expect(find.byType(StartChatScreen), findsOneWidget);
});
+ testWidgets('calling onBarcodeDetected with user deep link navigates to start chat', (
+ tester,
+ ) async {
+ await pumpScanNpubScreen(tester);
+
+ final scanBox = tester.widget(find.byType(QrScanner));
+ scanBox.onBarcodeDetected('whitenoise://user/$testNpubB');
+ await tester.pumpAndSettle();
+
+ final screen = tester.widget(find.byType(StartChatScreen));
+ expect(screen.userPubkey, testPubkeyB);
+ });
+
+ testWidgets('calling onBarcodeDetected with chat deep link navigates to chat', (
+ tester,
+ ) async {
+ await pumpScanNpubScreen(tester);
+
+ final scanBox = tester.widget(find.byType(QrScanner));
+ scanBox.onBarcodeDetected('whitenoise://chat/$testGroupId');
+ await tester.pumpAndSettle();
+
+ final screen = tester.widget(find.byType(ChatScreen));
+ expect(screen.groupId, testGroupId);
+ });
+
testWidgets('calling onBarcodeDetected with non-npub value does nothing', (tester) async {
await pumpScanNpubScreen(tester);
diff --git a/test/screens/share_profile_screen_test.dart b/test/screens/share_profile_screen_test.dart
index 896c9dc..509404b 100644
--- a/test/screens/share_profile_screen_test.dart
+++ b/test/screens/share_profile_screen_test.dart
@@ -1,12 +1,16 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData;
import 'package:flutter_test/flutter_test.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:whitenoise/providers/auth_provider.dart';
+import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/screens/chat_list_screen.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
+import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_copy_card.dart';
@@ -52,12 +56,20 @@ void main() {
_mockApi.reset();
});
- Future pumpShareProfileScreen(WidgetTester tester) async {
+ Future pumpShareProfileScreen(
+ WidgetTester tester, {
+ List overrides = const [],
+ Future Function()? deepLinkScheme,
+ }) async {
await mountTestApp(
tester,
overrides: [
authProvider.overrideWith(() => _MockAuthNotifier()),
secureStorageProvider.overrideWithValue(MockSecureStorage()),
+ deepLinkSchemeProvider.overrideWith(
+ (ref) => deepLinkScheme?.call() ?? Future.value(DeepLinks.productionScheme),
+ ),
+ ...overrides,
],
);
await tester.pumpAndSettle();
@@ -88,6 +100,32 @@ void main() {
expect(find.byType(QrImageView), findsOneWidget);
});
+ testWidgets('QR code encodes user deep link', (tester) async {
+ await pumpShareProfileScreen(tester);
+
+ expect(find.byKey(ValueKey(DeepLinks.userUri(testNpubA))), findsOneWidget);
+ });
+
+ testWidgets('defers QR code until deep link scheme is available', (tester) async {
+ final schemeCompleter = Completer();
+
+ await pumpShareProfileScreen(
+ tester,
+ deepLinkScheme: () => schemeCompleter.future,
+ );
+
+ expect(find.byType(WnCopyCard), findsOneWidget);
+ expect(find.byType(QrImageView), findsNothing);
+
+ schemeCompleter.complete(DeepLinks.stagingScheme);
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(ValueKey(DeepLinks.userUri(testNpubA, scheme: DeepLinks.stagingScheme))),
+ findsOneWidget,
+ );
+ });
+
testWidgets('tapping back button returns to previous screen', (tester) async {
await pumpShareProfileScreen(tester);
await tester.tap(find.byKey(const Key('slate_back_button')));
diff --git a/test/scripts/release_automation_config_test.sh b/test/scripts/release_automation_config_test.sh
index 78ad723..bc357db 100755
--- a/test/scripts/release_automation_config_test.sh
+++ b/test/scripts/release_automation_config_test.sh
@@ -41,6 +41,23 @@ assert_contains "$fastfile" "[[:space:]]*ios_app_identifier: 'org\\.parres\\.whi
android_gradle="$(cat android/app/build.gradle.kts)"
assert_contains "$android_gradle" '[[:space:]]*applicationIdSuffix = "\.staging"'
assert_contains "$android_gradle" '[[:space:]]*applicationId = "org\.parres\.whitenoise"'
+assert_contains "$android_gradle" '[[:space:]]*manifestPlaceholders\["deepLinkScheme"\] = "whitenoise-staging"'
+assert_contains "$android_gradle" '[[:space:]]*manifestPlaceholders\["deepLinkScheme"\] = "whitenoise"'
+deep_link_scheme_count="$(printf '%s\n' "$android_gradle" | grep -c 'manifestPlaceholders\["deepLinkScheme"\]')"
+if [ "$deep_link_scheme_count" -ne 2 ]; then
+ fail "expected each Android flavor to define exactly one deepLinkScheme placeholder"
+fi
+
+android_manifest="$(cat android/app/src/main/AndroidManifest.xml)"
+assert_contains "$android_manifest" '[[:space:]]*'
+
+ios_info="$(cat ios/Runner/Info.plist)"
+assert_contains "$ios_info" '[[:space:]]*CFBundleURLTypes'
+assert_contains "$ios_info" '[[:space:]]*\$\(DEEPLINK_SCHEME\)'
+
+ios_project="$(cat ios/Runner.xcodeproj/project.pbxproj)"
+assert_contains "$ios_project" '[[:space:]]*DEEPLINK_SCHEME = "whitenoise";'
+assert_contains "$ios_project" '[[:space:]]*DEEPLINK_SCHEME = "whitenoise-staging";'
ruby <<'RUBY'
require 'yaml'
diff --git a/test/utils/deep_links_test.dart b/test/utils/deep_links_test.dart
new file mode 100644
index 0000000..c982ef5
--- /dev/null
+++ b/test/utils/deep_links_test.dart
@@ -0,0 +1,99 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:whitenoise/src/rust/frb_generated.dart';
+import 'package:whitenoise/utils/deep_links.dart';
+
+import '../mocks/mock_wn_api.dart';
+import '../test_helpers.dart';
+
+void main() {
+ setUpAll(() {
+ RustLib.initMock(api: MockWnApi());
+ });
+
+ group('DeepLinks.parse', () {
+ test('maps production user links to the user profile route', () {
+ final target = DeepLinks.parse(Uri.parse('whitenoise://user/$testNpubB'));
+
+ expect(target?.location, '/start-chat/$testPubkeyB');
+ });
+
+ test('maps staging user links to the user profile route', () {
+ final target = DeepLinks.parse(Uri.parse('whitenoise-staging://user/$testNpubB'));
+
+ expect(target?.location, '/start-chat/$testPubkeyB');
+ });
+
+ test('maps triple-slash user links to the user profile route', () {
+ final target = DeepLinks.parse(Uri.parse('whitenoise:///user/$testNpubB'));
+
+ expect(target?.location, '/start-chat/$testPubkeyB');
+ });
+
+ test('maps chat links to the chat route', () {
+ final target = DeepLinks.parse(Uri.parse('whitenoise://chat/$testGroupId'));
+
+ expect(target?.location, '/chats/$testGroupId');
+ });
+
+ test('maps settings links to settings routes', () {
+ const expected = {
+ 'whitenoise://settings': '/settings',
+ 'whitenoise://settings/share-profile': '/share-profile',
+ 'whitenoise://settings/switch-profile': '/switch-profile',
+ 'whitenoise://settings/edit-profile': '/edit-profile',
+ 'whitenoise://settings/profile-keys': '/profile-keys',
+ 'whitenoise://settings/network': '/network',
+ 'whitenoise://settings/privacy-security': '/privacy-security',
+ 'whitenoise://settings/appearance': '/appearance',
+ 'whitenoise://settings/notifications': '/notification-settings',
+ 'whitenoise://settings/report-bug': '/report-bug',
+ 'whitenoise://settings/donate': '/donate',
+ 'whitenoise://settings/developer': '/developer-settings',
+ 'whitenoise://settings/developer/key-packages': '/key-package-management',
+ 'whitenoise://settings/developer/relay-state': '/relay-control-state',
+ 'whitenoise://settings/developer/app-logs': '/app-logs',
+ };
+
+ for (final entry in expected.entries) {
+ expect(
+ DeepLinks.parse(Uri.parse(entry.key))?.location,
+ entry.value,
+ reason: entry.key,
+ );
+ }
+ });
+
+ test('returns null for unsupported schemes and unknown paths', () {
+ expect(DeepLinks.parse(Uri.parse('https://user/$testNpubB')), isNull);
+ expect(DeepLinks.parse(Uri.parse('whitenoise://unknown/$testNpubB')), isNull);
+ expect(DeepLinks.parse(Uri.parse('whitenoise://user/npub1invalid')), isNull);
+ expect(DeepLinks.parse(Uri.parse('whitenoise://settings/sign-out')), isNull);
+ });
+ });
+
+ group('DeepLinks.userUri', () {
+ test('builds production user links by default', () {
+ expect(DeepLinks.userUri(testNpubB), 'whitenoise://user/$testNpubB');
+ });
+
+ test('builds staging user links with the staging scheme', () {
+ expect(
+ DeepLinks.userUri(testNpubB, scheme: DeepLinks.stagingScheme),
+ 'whitenoise-staging://user/$testNpubB',
+ );
+ });
+ });
+
+ group('DeepLinks.chatUri', () {
+ test('builds production chat links by default', () {
+ expect(DeepLinks.chatUri(testGroupId), 'whitenoise://chat/$testGroupId');
+ });
+
+ test('builds staging chat links with the staging scheme', () {
+ expect(
+ DeepLinks.chatUri(testGroupId, scheme: DeepLinks.stagingScheme),
+ 'whitenoise-staging://chat/$testGroupId',
+ );
+ });
+ });
+}