From 2d3ed8b092420f20c6dcdc7c9eef605429c67f7d Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sun, 5 Apr 2026 22:19:56 -0400 Subject: [PATCH] feat: universal links --- android/app/src/main/AndroidManifest.xml | 24 ++++++ android/app/src/main/res/values/strings.xml | 12 +++ ios/Runner/Runner.entitlements | 4 + lib/src/core/utils/share_urls.dart | 92 +++++++++++++++++++-- lib/src/sprk_app.dart | 18 +++- 5 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/res/values/strings.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 806f7b7..52ad577 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,10 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" > + + + + + + + + + + + + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..a7f8c72 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:sprk.so + diff --git a/lib/src/core/utils/share_urls.dart b/lib/src/core/utils/share_urls.dart index af4c3a3..1536fc6 100644 --- a/lib/src/core/utils/share_urls.dart +++ b/lib/src/core/utils/share_urls.dart @@ -13,25 +13,99 @@ String normalizeSparkPostUri(String postUri) { return normalizedPostUri; } +String? canonicalizeSparkPostUri(String postUri) { + final trimmedPostUri = postUri.trim(); + if (trimmedPostUri.isEmpty) { + return null; + } + + final canonicalMatch = _parseCanonicalSparkUri(trimmedPostUri); + if (canonicalMatch != null) { + return trimmedPostUri; + } + + final shortUriMatch = RegExp( + r'^(did:[^/]+)/([^/?#]+)$', + ).firstMatch(normalizeSparkPostUri(trimmedPostUri)); + if (shortUriMatch == null) { + return null; + } + + final did = shortUriMatch.group(1)!; + final rkey = shortUriMatch.group(2)!; + return 'at://$did/so.sprk.feed.post/$rkey'; +} + +({String did, String rkey})? _parseCanonicalSparkUri(String uri) { + final match = RegExp( + r'^at://([^/]+)/so\.sprk\.feed\.post/([^/?#]+)$', + ).firstMatch(uri); + if (match == null) return null; + return (did: match.group(1)!, rkey: match.group(2)!); +} + String buildSparkShareUrl(String postUri) { final normalizedPostUri = normalizeSparkPostUri(postUri); + final parts = _parseCanonicalSparkUri(normalizedPostUri); + if (parts == null) { + return Uri.https(_sparkShareHost, '/watch', { + 'uri': normalizedPostUri, + }).toString(); + } - return Uri.https(_sparkShareHost, '/watch', { - 'uri': normalizedPostUri, - }).toString(); + return Uri.https( + _sparkShareHost, + '/post/${parts.did}/${parts.rkey}', + ).toString(); } String? extractSparkPostUri(String url) { + final canonicalPostUri = extractCanonicalSparkPostUri(url); + if (canonicalPostUri == null) { + return null; + } + + return normalizeSparkPostUri(canonicalPostUri); +} + +String? extractCanonicalSparkPostUri(String url) { try { final uri = Uri.parse(url); - final postUri = uri.queryParameters['uri']; - final isSparkHost = uri.host == _sparkShareHost && uri.path == '/watch'; + final isSparkHost = uri.host == _sparkShareHost; final isLegacySparkHost = uri.host == _legacySparkShareHost; - if ((isSparkHost || isLegacySparkHost) && - postUri != null && - postUri.isNotEmpty) { - return normalizeSparkPostUri(postUri); + if (isSparkHost || isLegacySparkHost) { + final postPathMatch = RegExp( + r'^/post/([^/]+)/([^/?#]+)$', + ).firstMatch(uri.path); + if (postPathMatch != null) { + final identifier = postPathMatch.group(1)!; + final rkey = postPathMatch.group(2)!; + final did = identifier.startsWith('did:') + ? identifier + : 'did:plc:$identifier'; + return 'at://$did/so.sprk.feed.post/$rkey'; + } + + final postUri = uri.queryParameters['uri']; + if ((uri.path == '/watch' || isLegacySparkHost) && + postUri != null && + postUri.isNotEmpty) { + return canonicalizeSparkPostUri(postUri); + } + + final shortDid = uri.queryParameters['u']; + final rkey = uri.queryParameters['p']; + if ((uri.path == '/watch' || isLegacySparkHost) && + shortDid != null && + shortDid.isNotEmpty && + rkey != null && + rkey.isNotEmpty) { + final did = shortDid.startsWith('did:') + ? shortDid + : 'did:plc:$shortDid'; + return 'at://$did/so.sprk.feed.post/$rkey'; + } } } catch (_) { // Ignore malformed URLs. diff --git a/lib/src/sprk_app.dart b/lib/src/sprk_app.dart index 657f06c..036f95e 100644 --- a/lib/src/sprk_app.dart +++ b/lib/src/sprk_app.dart @@ -6,6 +6,7 @@ import 'package:spark/src/core/design_system/theme/app_theme.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/ui/theme/providers/theme_provider.dart'; +import 'package:spark/src/core/utils/share_urls.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/logging/logger.dart'; import 'package:spark/src/features/feed/providers/feed_provider.dart'; @@ -89,7 +90,22 @@ class _SprkAppState extends ConsumerState { } return supportedLocales.first; }, - routerConfig: _appRouter.config(), + routerConfig: _appRouter.config( + deepLinkTransformer: _transformIncomingDeepLink, + ), + ); + } + + Future _transformIncomingDeepLink(Uri uri) async { + final canonicalPostUri = extractCanonicalSparkPostUri(uri.toString()); + if (canonicalPostUri == null) { + return uri; + } + + final transformedUri = Uri( + path: '/post/${Uri.encodeComponent(canonicalPostUri)}', ); + _logger.i('Transformed incoming deep link from $uri to $transformedUri'); + return transformedUri; } } -- 2.51.2