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