mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Narrow down deep links
This commit is contained in:
@@ -44,15 +44,42 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- App Links for https://zapstore.dev/apps/... -->
|
||||
<!-- App Links for https://zapstore.dev/{apps,stacks}/...
|
||||
Scoped narrowly so unrelated paths (e.g. /about, /blog,
|
||||
/applications) are NOT claimed and stay in the browser.
|
||||
NOTE: Android intent filters cannot match query strings;
|
||||
anything matching these paths is delivered to the app
|
||||
regardless of `?…`, and resolved at runtime by
|
||||
`resolveDeepLinkPath`. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<!-- /apps (exact, for /apps?q=… search deep links) -->
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
android:pathPrefix="/apps" />
|
||||
android:path="/apps" />
|
||||
<!-- /apps/<id> (trailing slash avoids the /applications footgun) -->
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
android:pathPrefix="/apps/" />
|
||||
<!-- /stacks (exact, browse all stacks) -->
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
android:path="/stacks" />
|
||||
<!-- /stacks/<id> -->
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
android:pathPrefix="/stacks/" />
|
||||
<!-- /profile/<npub> (user profile pages) -->
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="zapstore.dev"
|
||||
android:pathPrefix="/profile/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle market:// URIs (e.g., market://details?id=com.example.app) -->
|
||||
|
||||
@@ -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<String>('');
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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/<id> OR bare /apps/<id> from GoRouter
|
||||
// https://zapstore.dev/stacks/<id> OR bare /stacks/<id>
|
||||
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=<query> — 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/<id> or /stacks/<id>
|
||||
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/<npub-or-hex-pubkey> — 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();
|
||||
}
|
||||
|
||||
@@ -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/<id> -> /search/app/<id>', () {
|
||||
expect(
|
||||
resolveDeepLinkPath(Uri.parse('https://zapstore.dev/apps/com.foo')),
|
||||
'/search/app/com.foo',
|
||||
);
|
||||
});
|
||||
|
||||
test('https zapstore.dev /stacks/<id> -> /search/stack/<id>', () {
|
||||
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/<id> (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/<id>', () {
|
||||
// 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/<npub> 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=<pkg> -> /search/app/<pkg>', () {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user