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', + ); + }); + }); +}