Handle market intent and deeplinks, fixes #195

This commit is contained in:
franzap
2025-12-11 20:16:53 -03:00
parent 1ae61abab2
commit 75e54aec81
11 changed files with 264 additions and 127 deletions
+8
View File
@@ -52,6 +52,14 @@
android:host="zapstore.dev"
android:pathPrefix="/apps" />
</intent-filter>
<!-- Handle market:// URIs (e.g., market://details?id=com.example.app) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="market" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
+4
View File
@@ -18,6 +18,7 @@ import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/services/package_manager/android_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/market_intent_service.dart';
import 'package:zapstore/utils/extensions.dart';
/// Global provider container for error reporting (accessible outside widget tree)
@@ -235,6 +236,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
final packageManager = ref.read(packageManagerProvider.notifier);
await packageManager.syncInstalledPackages();
// Initialize market intent handling (for market:// URIs)
await ref.read(marketIntentServiceProvider).initialize();
await _attemptAutoSignIn(ref);
});
+27 -40
View File
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
@@ -8,42 +7,16 @@ import 'package:zapstore/screens/developer_screen.dart';
import 'package:zapstore/screens/search_screen.dart';
import 'package:zapstore/screens/updates_screen.dart';
import 'package:zapstore/screens/profile_screen.dart';
import 'package:zapstore/screens/app_from_naddr_screen.dart';
/// Fallback screen shown when app data is not available
class _AppNotFoundScreen extends StatelessWidget {
const _AppNotFoundScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('App Not Found')),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64),
SizedBox(height: 16),
Text('App data not available'),
SizedBox(height: 8),
Text('Please try navigating from the app list'),
],
),
),
);
}
}
/// Helper to build app detail route with fallback
/// Helper to build app detail route
/// Accepts App via extra, or falls back to loading by :id parameter
GoRoute _appDetailRoute() {
return GoRoute(
path: 'app/:id',
builder: (context, state) {
final app = state.extra as App?;
if (app != null) {
return AppDetailScreen(app: app);
}
return const _AppNotFoundScreen();
final appId = state.pathParameters['id']!;
return AppDetailScreen(app: app, appId: appId);
},
);
}
@@ -64,7 +37,7 @@ final routerProvider = Provider<GoRouter>((ref) {
initialLocation: '/search',
routes: [
// Top-level deep link route: /apps/:naddr
// Redirect to profile branch to show with nav bar
// Redirect to search branch to show with nav bar
GoRoute(
path: '/apps/:naddr',
redirect: (context, state) {
@@ -72,7 +45,28 @@ final routerProvider = Provider<GoRouter>((ref) {
if (naddr == null || naddr.isEmpty) {
return '/search';
}
return '/profile/apps/$naddr';
// Decode naddr to get app identifier
try {
final decoded = Utils.decodeShareableIdentifier(naddr);
if (decoded is AddressData && decoded.identifier.isNotEmpty) {
return '/search/app/${decoded.identifier}';
}
} catch (_) {
// Invalid naddr
}
return '/search';
},
),
// Top-level route for market:// intents
// Redirect to search branch to show with nav bar
GoRoute(
path: '/market/:packageId',
redirect: (context, state) {
final packageId = state.pathParameters['packageId'];
if (packageId == null || packageId.isEmpty) {
return '/search';
}
return '/search/app/$packageId';
},
),
// Single stateful shell route that handles everything
@@ -114,13 +108,6 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const ProfileScreen(),
routes: [
_appDetailRoute(),
GoRoute(
path: 'apps/:naddr',
builder: (context, state) {
final naddr = state.pathParameters['naddr']!;
return AppFromNaddrScreen(naddr: naddr);
},
),
_developerRoute(),
],
),
+94 -1
View File
@@ -7,6 +7,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:zapstore/utils/debug_utils.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/services/profile_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/widgets/app_detail_widgets.dart';
import 'package:zapstore/widgets/app_header.dart';
import 'package:zapstore/widgets/app_info_table.dart';
@@ -19,9 +20,101 @@ import 'package:zapstore/widgets/screenshots_gallery.dart';
import 'package:zapstore/widgets/version_pill_widget.dart';
class AppDetailScreen extends HookConsumerWidget {
const AppDetailScreen({super.key, required this.app});
const AppDetailScreen({super.key, this.app, this.appId})
: assert(app != null || appId != null);
/// The app to display (if already loaded)
final App? app;
/// The app identifier to load (used for deep links / market:// intents)
final String? appId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// If we only have appId, query for the app first
if (app == null && appId != null) {
return _AppLoaderView(appId: appId!);
}
return _AppDetailView(app: app!);
}
}
/// Internal view that loads an app by ID
class _AppLoaderView extends ConsumerWidget {
final String appId;
const _AppLoaderView({required this.appId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final platform = ref.read(packageManagerProvider.notifier).platform;
final appsState = ref.watch(
query<App>(
tags: {
'#d': {appId},
'#f': {platform},
},
limit: 1,
source: const LocalAndRemoteSource(stream: false, background: true),
subscriptionPrefix: 'app-detail-loader',
),
);
final app = appsState.models.firstOrNull;
if (app != null) {
return _AppDetailView(app: app);
}
// Handle states
switch (appsState) {
case StorageError(:final exception):
return _ErrorScaffold(message: exception.toString());
case StorageData():
return _ErrorScaffold(message: 'App "$appId" not found in Zapstore.');
default:
break;
}
// Loading state
return const Scaffold(
body: SafeArea(
child: Padding(padding: EdgeInsets.all(16), child: AppDetailSkeleton()),
),
);
}
}
class _ErrorScaffold extends StatelessWidget {
final String message;
const _ErrorScaffold({required this.message});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Open App')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(message, textAlign: TextAlign.center),
),
],
),
),
);
}
}
/// Internal view that displays app details (app already loaded)
class _AppDetailView extends HookConsumerWidget {
final App app;
const _AppDetailView({required this.app});
@override
Widget build(BuildContext context, WidgetRef ref) {
-86
View File
@@ -1,86 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/widgets/app_detail_widgets.dart';
import 'package:zapstore/screens/app_detail_screen.dart';
class AppFromNaddrScreen extends ConsumerWidget {
final String naddr;
const AppFromNaddrScreen({super.key, required this.naddr});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Decode naddr
AddressData? address;
try {
final decoded = Utils.decodeShareableIdentifier(naddr);
if (decoded is AddressData) {
address = decoded;
}
} catch (_) {
// ignore - handled below
}
if (address == null || address.identifier.isEmpty) {
return _ErrorScaffold(message: 'Invalid link. Not a valid naddr.');
}
// Query the App by its identifier (d-tag)
final appsState = ref.watch(
query<App>(
tags: {
'#d': {address.identifier},
},
limit: 1,
source: LocalAndRemoteSource(stream: false, background: true),
subscriptionPrefix: 'naddr-app',
),
);
final app = appsState.models.firstOrNull;
if (app != null) {
return AppDetailScreen(app: app);
}
// Handle error state
switch (appsState) {
case StorageError(:final exception):
return _ErrorScaffold(message: exception.toString());
default:
break;
}
// Loading state
return const Scaffold(
body: SafeArea(
child: Padding(padding: EdgeInsets.all(16), child: AppDetailSkeleton()),
),
);
}
}
class _ErrorScaffold extends StatelessWidget {
final String message;
const _ErrorScaffold({required this.message});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Open App')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
],
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/router.dart';
/// Provider that initializes market:// intent handling
///
/// Listens for incoming market:// URIs (e.g., market://details?id=com.example.app)
/// and navigates to the app detail screen.
final marketIntentServiceProvider = Provider<MarketIntentService>((ref) {
return MarketIntentService(ref);
});
class MarketIntentService {
final Ref _ref;
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _subscription;
bool _initialized = false;
MarketIntentService(this._ref);
GoRouter get _router => _ref.read(routerProvider);
/// Initialize the service - call once after app is ready
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
// Handle initial link (app launched via intent)
final initialUri = await _appLinks.getInitialLink();
_handleUri(initialUri);
// Handle links while app is running
_subscription = _appLinks.uriLinkStream.listen(_handleUri);
}
void _handleUri(Uri? uri) {
final packageId = _extractPackageId(uri);
if (packageId != null) {
_router.go('/search/app/$packageId');
}
}
void dispose() {
_subscription?.cancel();
}
}
/// Extract package ID from a URI
///
/// Handles:
/// - market://details?id=com.example.app
/// - market://search?q=search+query
String? _extractPackageId(Uri? uri) {
if (uri == null) return null;
if (uri.scheme != 'market') return null;
// market://details?id=com.example.app
if (uri.host == 'details' || uri.path == '/details') {
final id = uri.queryParameters['id'];
if (id != null && id.isNotEmpty) {
debugPrint('Market intent: app ID = $id');
return id;
}
}
// market://search?q=query
if (uri.host == 'search' || uri.path == '/search') {
final query = uri.queryParameters['q'];
if (query != null && query.isNotEmpty) {
debugPrint('Market intent: search query = $query');
return query;
}
}
debugPrint('Market intent: unhandled URI = $uri');
return null;
}
@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
gtk
sqlite3_flutter_libs
url_launcher_linux
)
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import app_links
import connectivity_plus
import path_provider_foundation
import share_plus
@@ -14,6 +15,7 @@ import sqlite3_flutter_libs
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
+40
View File
@@ -18,6 +18,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: "direct dev"
description:
@@ -409,6 +441,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "16.1.0"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
hooks_riverpod:
dependency: "direct main"
description:
+1
View File
@@ -52,6 +52,7 @@ dependencies:
share_plus: ^12.0.0
background_downloader: ^9.2.6
connectivity_plus: ^7.0.0
app_links: ^6.4.1
dependency_overrides:
models: