mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Handle market intent and deeplinks, fixes #195
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user