diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e3956e2..4c97e0b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,15 +44,42 @@ - + + + android:path="/apps" /> + + + + + + + + diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index eb1a904..e96b629 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/utils/nostr_route.dart'; @@ -22,6 +23,22 @@ class SearchScreen extends HookConsumerWidget { final searchFocusNode = useFocusNode(); final searchQuery = useState(''); + // Seed search from `/search?q=...` deep links. Re-runs whenever the + // route's `q` parameter changes; in-screen edits don't change the URL, + // so they are not clobbered by this effect. + final routeQuery = + GoRouterState.of(context).uri.queryParameters['q']?.trim() ?? ''; + useEffect(() { + if (routeQuery.isNotEmpty) { + searchController.text = routeQuery; + searchController.selection = TextSelection.collapsed( + offset: routeQuery.length, + ); + searchQuery.value = routeQuery; + } + return null; + }, [routeQuery]); + // Skeleton unlocks as soon as local storage is usable. Gating on // `appInitializationProvider` would wait on the full init chain // (including network warm-ups), which violates local-first. diff --git a/lib/services/deep_link_resolver.dart b/lib/services/deep_link_resolver.dart index 7ceb917..ff919bf 100644 --- a/lib/services/deep_link_resolver.dart +++ b/lib/services/deep_link_resolver.dart @@ -1,29 +1,57 @@ +import 'package:models/models.dart'; import 'package:zapstore/services/log_service.dart'; +/// 64-character hex pubkey (case-insensitive). Normalised to lowercase by +/// the resolver since downstream Nostr filters require it. +final _hexPubkey64 = RegExp(r'^[0-9a-fA-F]{64}$'); + /// Converts a deep link URI into a router path string, or returns null if /// the URI is not a recognized deep link. /// /// Handles: /// - `https://zapstore.dev/apps/{id}` and `.../stacks/{id}` -/// - `/apps/{id}` and `/stacks/{id}` (path-only, as seen by GoRouter's onException) +/// - `https://zapstore.dev/apps?q=foo` (search via query parameter) +/// - `https://zapstore.dev/stacks` (browse all stacks) +/// - `https://zapstore.dev/profile/{npub}` (user profile) +/// - Equivalent bare paths (as seen by GoRouter's `onException`) /// - `market://details?id=com.example.app` /// - `market://search?q=search+query` String? resolveDeepLinkPath(Uri uri) { - // https://zapstore.dev/apps/ OR bare /apps/ from GoRouter - // https://zapstore.dev/stacks/ OR bare /stacks/ - if (uri.pathSegments.length == 2) { + final isZapstoreHttps = + uri.scheme == 'https' && uri.host == 'zapstore.dev'; + final isBarePath = uri.scheme.isEmpty && uri.host.isEmpty; + final isZapstoreUri = isZapstoreHttps || isBarePath; + + // /apps or /apps?q= — search entry point + if (isZapstoreUri && + uri.pathSegments.length == 1 && + uri.pathSegments[0] == 'apps') { + return _searchPath(uri.queryParameters['q']); + } + + // /stacks — browse all stacks + if (isZapstoreUri && + uri.pathSegments.length == 1 && + uri.pathSegments[0] == 'stacks') { + return '/search/stacks'; + } + + // /apps/ or /stacks/ + if (isZapstoreUri && uri.pathSegments.length == 2) { final section = uri.pathSegments[0]; if (section == 'apps' || section == 'stacks') { - final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev'; - final isBarePath = uri.host.isEmpty; - if (isFullUri || isBarePath) { - final id = uri.pathSegments[1]; - if (id.isNotEmpty) { - final route = section == 'apps' ? 'app' : 'stack'; - return '/search/$route/$id'; - } + final id = uri.pathSegments[1]; + if (id.isNotEmpty) { + final route = section == 'apps' ? 'app' : 'stack'; + return '/search/$route/$id'; } } + + // /profile/ — validated and normalised to hex. + if (section == 'profile') { + final hex = _tryParsePubkey(uri.pathSegments[1]); + if (hex != null) return '/search/user/$hex'; + } } // market://details?id=com.example.app (Google Play-style intents) @@ -37,13 +65,13 @@ String? resolveDeepLinkPath(Uri uri) { if (uri.host == 'search' || uri.path == '/search') { final query = uri.queryParameters['q']; - if (query != null && query.isNotEmpty) { + if (query != null && query.trim().isNotEmpty) { LogService.I.debug( 'market intent: search query', tag: 'deep_link', fields: {'query': query}, ); - return '/search/app/$query'; + return _searchPath(query); } } @@ -56,3 +84,29 @@ String? resolveDeepLinkPath(Uri uri) { return null; } + +/// Returns the lowercase hex pubkey for [id] if it is either a valid +/// `npub1…` or a 64-character hex pubkey. Returns `null` for any other +/// shape — including `nprofile`, `nevent`, `naddr`, malformed bech32, +/// and arbitrary strings — so `UserScreen` is never instantiated with +/// a non-pubkey value. +String? _tryParsePubkey(String id) { + if (id.isEmpty) return null; + if (_hexPubkey64.hasMatch(id)) return id.toLowerCase(); + if (id.startsWith('npub1')) { + try { + final decoded = Utils.decodeShareableIdentifier(id); + if (decoded is ProfileData) return decoded.pubkey; + } catch (_) { + // Fall through to null. + } + } + return null; +} + +/// Build a `/search` path, optionally seeded with a `?q=` query parameter. +String _searchPath(String? query) { + final trimmed = query?.trim() ?? ''; + if (trimmed.isEmpty) return '/search'; + return Uri(path: '/search', queryParameters: {'q': trimmed}).toString(); +} diff --git a/test/services/deep_link_resolver_test.dart b/test/services/deep_link_resolver_test.dart new file mode 100644 index 0000000..f18c391 --- /dev/null +++ b/test/services/deep_link_resolver_test.dart @@ -0,0 +1,261 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/services/deep_link_resolver.dart'; + +void main() { + group('resolveDeepLinkPath', () { + group('app and stack detail links', () { + test('https zapstore.dev /apps/ -> /search/app/', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps/com.foo')), + '/search/app/com.foo', + ); + }); + + test('https zapstore.dev /stacks/ -> /search/stack/', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/stacks/abc')), + '/search/stack/abc', + ); + }); + + test('https zapstore.dev /stacks (no id) -> /search/stacks', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/stacks')), + '/search/stacks', + ); + }); + + test('bare /stacks (no id) -> /search/stacks', () { + expect( + resolveDeepLinkPath(Uri.parse('/stacks')), + '/search/stacks', + ); + }); + + test('bare /apps/ (GoRouter onException form)', () { + expect( + resolveDeepLinkPath(Uri.parse('/apps/com.foo')), + '/search/app/com.foo', + ); + }); + + test('http (not https) is not a recognised deep link', () { + expect( + resolveDeepLinkPath(Uri.parse('http://zapstore.dev/apps/com.foo')), + isNull, + ); + }); + + test('foreign host is not a recognised deep link', () { + expect( + resolveDeepLinkPath(Uri.parse('https://example.com/apps/com.foo')), + isNull, + ); + }); + + test('empty id segment returns null', () { + // Uri normalises trailing slash; /apps/ has one empty segment. + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps/')), + isNull, + ); + }); + }); + + group('search via /apps?q=', () { + test('https zapstore.dev /apps?q=foo -> /search?q=foo', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps?q=foo')), + '/search?q=foo', + ); + }); + + test('bare /apps?q=foo -> /search?q=foo', () { + expect( + resolveDeepLinkPath(Uri.parse('/apps?q=foo')), + '/search?q=foo', + ); + }); + + test('multi-word query is encoded', () { + final result = resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/apps?q=hello+world'), + ); + // Uri parses `+` as space; the search path should encode it back. + expect(result, isNotNull); + expect( + Uri.parse(result!).queryParameters['q'], + 'hello world', + ); + }); + + test('whitespace-only q is treated as empty -> /search', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps?q=%20%20')), + '/search', + ); + }); + + test('missing q -> /search (no query)', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps')), + '/search', + ); + }); + + test('extra unknown query params are ignored', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/apps?q=foo&utm_source=tweet'), + ), + '/search?q=foo', + ); + }); + }); + + group('user profile via /profile/', () { + // Real npub/hex pair from constants/app_constants.dart + // (Zapstore community pubkey). + const knownNpub = + 'npub14nl2afh9zsswsp5043zxe2w304afaa496gxe8z2w2rlw84ys92zqlnjx5u'; + const knownHex = + 'acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84'; + + test('valid npub is decoded to hex pubkey', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/$knownNpub'), + ), + '/search/user/$knownHex', + ); + }); + + test('bare /profile/ is also decoded', () { + expect( + resolveDeepLinkPath(Uri.parse('/profile/$knownNpub')), + '/search/user/$knownHex', + ); + }); + + test('64-char hex pubkey is accepted as-is', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/$knownHex'), + ), + '/search/user/$knownHex', + ); + }); + + test('uppercase hex is normalised to lowercase', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/${knownHex.toUpperCase()}'), + ), + '/search/user/$knownHex', + ); + }); + + test('malformed npub (right prefix, garbage body) returns null', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/npub1garbagenotreal'), + ), + isNull, + ); + }); + + test('non-hex non-npub string returns null', () { + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/randomtext'), + ), + isNull, + ); + }); + + test('short hex (<64 chars) returns null', () { + // Defensive: avoid sending truncated/partial pubkeys to UserScreen. + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/deadbeef'), + ), + isNull, + ); + }); + + test('hex with non-hex characters returns null', () { + // 64 chars, but contains 'z' — must be rejected. + final invalid = 'z' * 64; + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/profile/$invalid'), + ), + isNull, + ); + }); + + test('empty profile id returns null', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/profile/')), + isNull, + ); + }); + }); + + group('market:// intents', () { + test('market://details?id= -> /search/app/', () { + expect( + resolveDeepLinkPath(Uri.parse('market://details?id=com.foo')), + '/search/app/com.foo', + ); + }); + + test('market://search?q=foo -> /search?q=foo (not app detail)', () { + // Regression: previously routed to /search/app/foo, which opened + // AppDetailScreen with the search query as the app id. + expect( + resolveDeepLinkPath(Uri.parse('market://search?q=foo')), + '/search?q=foo', + ); + }); + + test('market://search with empty q returns null', () { + expect( + resolveDeepLinkPath(Uri.parse('market://search?q=')), + isNull, + ); + }); + + test('unknown market action returns null', () { + expect( + resolveDeepLinkPath(Uri.parse('market://launch?id=com.foo')), + isNull, + ); + }); + }); + + group('unrecognised input', () { + test('about:blank returns null', () { + expect(resolveDeepLinkPath(Uri.parse('about:blank')), isNull); + }); + + test('zapstore.dev /about returns null', () { + expect( + resolveDeepLinkPath(Uri.parse('https://zapstore.dev/about')), + isNull, + ); + }); + + test('zapstore.dev /applications/foo returns null', () { + // Guards against the loose `pathPrefix="/apps"` footgun where + // `/applications/...` would otherwise be interpreted as an app id. + expect( + resolveDeepLinkPath( + Uri.parse('https://zapstore.dev/applications/foo'), + ), + isNull, + ); + }); + }); + }); +}