From fe07b182ec9ace9de3db3763e3617ee2b52165a0 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 26 Mar 2026 10:15:12 +0100 Subject: [PATCH] custom pwa and shortcut support --- .../models/received_intent_parameter.dart | 5 +- app/lib/extensions/uri.dart | 2 + .../browser/domain/providers/intent.dart | 5 +- .../widgets/browser_menu_sheet.dart | 12 +- .../widgets/browser_modules/browser_view.dart | 27 +++ .../presentation/widgets/tab_menu.dart | 9 +- .../features/pwa/domain/providers.dart | 45 +++++ .../features/pwa/domain/providers.g.dart | 126 ++++++++++++ .../pwa/domain/pwa_installability.dart | 32 +-- .../dialogs/pwa_install_dialog.dart | 149 ++++++++++++-- .../widgets/pwa_install_button.dart | 71 ++++++- .../tabs/data/database/daos/container.dart | 8 + .../tabs/data/database/definitions.drift | 5 + .../tabs/data/database/definitions.drift.dart | 10 + .../tabs/domain/repositories/container.dart | 8 + .../tabs/domain/repositories/container.g.dart | 2 +- .../screens/browsing_settings.dart | 44 ++++ .../domain/entities/shared_content.dart | 21 +- .../domain/services/sharing_intent.dart | 18 +- .../user/data/models/general_settings.dart | 7 +- .../user/data/models/general_settings.g.dart | 16 ++ .../domain/repositories/general_settings.dart | 2 + .../repositories/general_settings.g.dart | 2 +- .../ExternalAppBrowserFragment.kt | 2 +- .../PwaConstants.kt | 5 + .../activities/IntentReceiverActivity.kt | 52 ++++- .../api/GeckoPwaApiImpl.kt | 191 +++++++++++++++++- .../pigeons/Gecko.g.kt | 37 ++++ .../lib/src/pigeons/gecko.g.dart | 38 ++++ .../pigeons/gecko.dart | 14 ++ 30 files changed, 896 insertions(+), 69 deletions(-) diff --git a/app/lib/data/models/received_intent_parameter.dart b/app/lib/data/models/received_intent_parameter.dart index 0a3cbde9..af5905a0 100644 --- a/app/lib/data/models/received_intent_parameter.dart +++ b/app/lib/data/models/received_intent_parameter.dart @@ -22,9 +22,10 @@ import 'package:fast_equatable/fast_equatable.dart'; class ReceivedIntentParameter with FastEquatable { final String? content; final String? tool; + final String? contextId; - ReceivedIntentParameter(this.content, this.tool); + ReceivedIntentParameter(this.content, this.tool, {this.contextId}); @override - List get hashParameters => [content, tool]; + List get hashParameters => [content, tool, contextId]; } diff --git a/app/lib/extensions/uri.dart b/app/lib/extensions/uri.dart index 90739a93..53e29797 100644 --- a/app/lib/extensions/uri.dart +++ b/app/lib/extensions/uri.dart @@ -30,6 +30,8 @@ extension UriX on Uri { bool get isHttps => isScheme('https'); bool get isHttpOrHttps => isHttp || isHttps; + bool get isLocalhost => host == 'localhost' || host == '127.0.0.1'; + /// Removes a bare root path (`/`) when there is no query or fragment, so /// that `https://example.com/` and `https://example.com` are treated as /// equivalent. diff --git a/app/lib/features/geckoview/features/browser/domain/providers/intent.dart b/app/lib/features/geckoview/features/browser/domain/providers/intent.dart index 4e1e54b0..00e41182 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers/intent.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers/intent.dart @@ -36,7 +36,10 @@ final _contentParserTransformer = StreamTransformer.fromHandlers( handleData: (parameter, sink) { final parsed = parameter.content.mapNotNull( - (content) => SharedContent.parse(content), + (content) => SharedContent.parse( + content, + contextId: parameter.contextId, + ), ); if (parsed != null) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index 430c0ec8..1906a3b4 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -681,8 +681,10 @@ class _AddToHomeScreenTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isInstallable = ref.watch(isCurrentTabInstallableProvider); + final isShortcutable = ref.watch(isCurrentTabShortcutableProvider); - if (!isInstallable) return const SizedBox.shrink(); + // Show for installable PWAs or any HTTPS page + if (!isInstallable && !isShortcutable) return const SizedBox.shrink(); return Column( children: [ @@ -691,7 +693,13 @@ class _AddToHomeScreenTile extends ConsumerWidget { leading: const Icon(Icons.add_to_home_screen), title: const Text('Add to Home Screen'), onTap: () async { - await showPwaInstallDialog(context, ref); + if (isInstallable) { + // Site has valid manifest — use existing PWA install flow + await showPwaInstallDialog(context, ref); + } else { + // No manifest — show shortcut choice dialog + await showShortcutInstallDialog(context, ref); + } if (context.mounted) Navigator.pop(context); }, ), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 2b7a3098..6c045e99 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -32,6 +32,7 @@ import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; @@ -355,6 +356,12 @@ class _BrowserViewState extends ConsumerState final router = await ref.read(routerProvider.future); final settings = ref.read(generalSettingsWithDefaultsProvider); + // Resolve container from shortcut intent context ID + final containerSelection = await _resolveContainerSelection( + ref, + sharedContent.contextId, + ); + switch (settings.tabIntentOpenSetting) { case TabIntentOpenSetting.regular: case TabIntentOpenSetting.private: @@ -371,6 +378,7 @@ class _BrowserViewState extends ConsumerState : TabMode.regular, launchedFromIntent: true, selectTab: true, + containerSelection: containerSelection, ); case SharedText(): final bang = @@ -642,3 +650,22 @@ class _BrowserViewState extends ConsumerState super.dispose(); } } + +/// Resolves a [TabContainerSelection] from a shortcut intent's context ID. +/// Returns [TabContainerSelection.useSelected] if no contextId or container not found. +Future _resolveContainerSelection( + WidgetRef ref, + String? contextId, +) async { + if (contextId == null) return const TabContainerSelection.useSelected(); + + final container = await ref + .read(containerRepositoryProvider.notifier) + .getContainerByContextualIdentity(contextId); + + if (container != null) { + return TabContainerSelection.specific(container); + } + + return const TabContainerSelection.useSelected(); +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 198f2692..0158a0ef 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -211,15 +211,20 @@ class TabMenu extends HookConsumerWidget { Consumer( builder: (context, ref, child) { final isInstallable = ref.watch(isCurrentTabInstallableProvider); + final isShortcutable = ref.watch(isCurrentTabShortcutableProvider); return Visibility( - visible: isInstallable, + visible: isInstallable || isShortcutable, child: MenuItemButton( closeOnActivate: false, leadingIcon: const Icon(Icons.add_to_home_screen), child: const Text('Add to Home Screen'), onPressed: () async { - await showPwaInstallDialog(context, ref); + if (isInstallable) { + await showPwaInstallDialog(context, ref); + } else { + await showShortcutInstallDialog(context, ref); + } if (context.mounted) { MenuController.maybeOf(context)?.close(); diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.dart b/app/lib/features/geckoview/features/pwa/domain/providers.dart index 6bf4eb0a..66b00fc0 100644 --- a/app/lib/features/geckoview/features/pwa/domain/providers.dart +++ b/app/lib/features/geckoview/features/pwa/domain/providers.dart @@ -24,8 +24,10 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/filesystem.dart' show filesystem; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -121,3 +123,46 @@ Future installCurrentWebApp(Ref ref) async { Future> installedWebApps(Ref ref) { return GeckoPwaApi().getInstalledWebApps(); } + +/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut). +@Riverpod() +bool isCurrentTabShortcutable(Ref ref) { + final selectedTabId = ref.watch(selectedTabProvider); + if (selectedTabId == null) return false; + + final tabState = ref.watch(tabStateProvider(selectedTabId)); + if (tabState == null) return false; + + return tabState.url.isHttps || + (tabState.url.isHttp && tabState.url.isLocalhost); +} + +/// Creates a basic bookmark shortcut on the home screen for the current tab. +@Riverpod() +Future installBasicShortcut(Ref ref, {String? overrideName}) async { + final selectedTabId = ref.read(selectedTabProvider); + + if (selectedTabId == null) { + throw StateError('No tab selected'); + } + + final profileUuid = filesystem.selectedProfile.uuid; + + final selectedContainerId = ref.read(selectedContainerProvider); + + String? contextId; + if (selectedContainerId != null) { + final containerRepository = ref.read(containerRepositoryProvider.notifier); + final containerData = await containerRepository.getContainerData( + selectedContainerId, + ); + contextId = containerData?.metadata.contextualIdentity; + } + + return GeckoPwaApi().installBasicShortcut( + selectedTabId, + profileUuid, + contextId, + overrideName, + ); +} diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart index 975d8922..5283581a 100644 --- a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart @@ -296,3 +296,129 @@ final class InstalledWebAppsProvider } String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d'; + +/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut). + +@ProviderFor(isCurrentTabShortcutable) +final isCurrentTabShortcutableProvider = IsCurrentTabShortcutableProvider._(); + +/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut). + +final class IsCurrentTabShortcutableProvider + extends $FunctionalProvider + with $Provider { + /// Whether the current tab is on an HTTPS page (eligible for home screen shortcut). + IsCurrentTabShortcutableProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isCurrentTabShortcutableProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isCurrentTabShortcutableHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return isCurrentTabShortcutable(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$isCurrentTabShortcutableHash() => + r'a18fa837facc92397dacb79f38bdefd303ff5d00'; + +/// Creates a basic bookmark shortcut on the home screen for the current tab. + +@ProviderFor(installBasicShortcut) +final installBasicShortcutProvider = InstallBasicShortcutFamily._(); + +/// Creates a basic bookmark shortcut on the home screen for the current tab. + +final class InstallBasicShortcutProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// Creates a basic bookmark shortcut on the home screen for the current tab. + InstallBasicShortcutProvider._({ + required InstallBasicShortcutFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'installBasicShortcutProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$installBasicShortcutHash(); + + @override + String toString() { + return r'installBasicShortcutProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String?; + return installBasicShortcut(ref, overrideName: argument); + } + + @override + bool operator ==(Object other) { + return other is InstallBasicShortcutProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$installBasicShortcutHash() => + r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2'; + +/// Creates a basic bookmark shortcut on the home screen for the current tab. + +final class InstallBasicShortcutFamily extends $Family + with $FunctionalFamilyOverride, String?> { + InstallBasicShortcutFamily._() + : super( + retry: null, + name: r'installBasicShortcutProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Creates a basic bookmark shortcut on the home screen for the current tab. + + InstallBasicShortcutProvider call({String? overrideName}) => + InstallBasicShortcutProvider._(argument: overrideName, from: this); + + @override + String toString() => r'installBasicShortcutProvider'; +} diff --git a/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart b/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart index bb107152..95e61d85 100644 --- a/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart +++ b/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart @@ -19,6 +19,8 @@ */ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/extensions/uri.dart'; /// Display modes that are valid for installable PWAs per W3C spec. const _validDisplayModes = { @@ -40,11 +42,16 @@ const _validDisplayModes = { /// 3. start_url is within the scope /// 4. prefer_related_applications is not true bool isManifestInstallable(PwaManifest manifest) { + final currentUrl = Uri.tryParse(manifest.currentUrl); + final startUrl = Uri.tryParse(manifest.startUrl); + + if (currentUrl == null) { + return false; + } + // Check HTTPS requirement (relaxed for localhost) final isSecure = - manifest.currentUrl.startsWith('https://') || - manifest.currentUrl.startsWith('http://localhost') || - manifest.currentUrl.startsWith('http://127.0.0.1'); + currentUrl.isHttps || (currentUrl.isHttp && currentUrl.isLocalhost); if (!isSecure) { return false; @@ -62,24 +69,23 @@ bool isManifestInstallable(PwaManifest manifest) { // W3C §1.10.6: start_url must be same-origin as the document URL final hasValidStartUrl = - manifest.startUrl.isNotEmpty && - _isSameOrigin(manifest.startUrl, manifest.currentUrl); + startUrl != null && _isSameOrigin(startUrl, currentUrl); final hasValidDisplay = manifest.display != null && _validDisplayModes.contains(manifest.display!.toLowerCase()); // Check that start_url is within scope - final isInScope = _isStartUrlInScope(manifest.startUrl, manifest.scope); + final isInScope = + hasValidStartUrl && + _isStartUrlInScope(startUrl, manifest.scope.mapNotNull(Uri.tryParse)); return hasValidName && hasValidStartUrl && hasValidDisplay && isInScope; } /// Returns true if two URLs share the same origin (scheme + host + port). -bool _isSameOrigin(String url1, String url2) { +bool _isSameOrigin(Uri uri1, Uri uri2) { try { - final uri1 = Uri.parse(url1); - final uri2 = Uri.parse(url2); return uri1.scheme == uri2.scheme && uri1.host == uri2.host && uri1.port == uri2.port; @@ -93,18 +99,14 @@ bool _isSameOrigin(String url1, String url2) { /// Per W3C spec: when scope is absent, the default scope is the start_url /// with its last path segment, query, and fragment removed. /// Scope matching uses path-prefix comparison on `/` boundaries. -bool _isStartUrlInScope(String startUrl, String? scope) { +bool _isStartUrlInScope(Uri startUri, Uri? scopeUri) { try { - final startUri = Uri.parse(startUrl); - - if (scope == null || scope.isEmpty) { + if (scopeUri == null) { // Per W3C: default scope = start_url with filename/query/fragment removed // The start_url is trivially within its own default scope. return true; } - final scopeUri = Uri.parse(scope); - // Must be same origin if (startUri.scheme != scopeUri.scheme || startUri.host != scopeUri.host || diff --git a/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart b/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart index 1006f1ba..a0b27e63 100644 --- a/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart +++ b/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart @@ -18,26 +18,141 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; -/// Shows a dialog asking the user to confirm adding a PWA to the home screen. +/// The type of home screen shortcut the user chose. +enum ShortcutInstallType { shortcut, app } + +/// Shows a bottom sheet to confirm adding a PWA to the home screen. /// -/// Returns true if the user confirms, false if cancelled. -Future showPwaInstallConfirmDialog(BuildContext context, String name) { - return showDialog( +/// Returns true if the user confirms, null if dismissed. +Future showPwaInstallBottomSheet( + BuildContext context, { + required String name, + required Uri url, +}) { + return showModalBottomSheet( context: context, - builder: (context) => AlertDialog( - title: const Text('Add to Home Screen'), - content: Text('Add "$name" to your home screen?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Add'), - ), - ], + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ShortcutSheetHeader(name: name, url: url), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.install_mobile), + title: const Text('Install as App'), + subtitle: const Text('Runs standalone with its own window.'), + onTap: () => Navigator.of(context).pop(true), + ), + ], + ), ), ); } + +/// Shows a bottom sheet for non-manifest sites offering a choice between +/// "Add Shortcut" (opens in browser) and "Install as App" (standalone mode). +/// +/// [showAppOption] controls whether the "Install as App" option is visible +/// (requires the allowNonManifestPwaInstall setting to be enabled). +/// +/// Returns [ShortcutInstallType] or null if dismissed. +Future showShortcutChoiceBottomSheet( + BuildContext context, { + required String name, + required Uri url, + required bool showAppOption, +}) { + return showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ShortcutSheetHeader(name: name, url: url), + const Divider(height: 1), + if (showAppOption) + ListTile( + leading: const Icon(Icons.install_mobile), + title: const Text('Install as App'), + subtitle: const Text('Runs standalone with its own window.'), + onTap: () => + Navigator.of(context).pop(ShortcutInstallType.app), + ), + ListTile( + leading: const Icon(Icons.shortcut), + title: const Text('Add Shortcut'), + subtitle: const Text('Opens as a standard tab in the browser.'), + onTap: () => + Navigator.of(context).pop(ShortcutInstallType.shortcut), + ), + ], + ), + ), + ); +} + +class _ShortcutSheetHeader extends StatelessWidget { + final String name; + final Uri url; + + const _ShortcutSheetHeader({required this.name, required this.url}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Add to Home Screen', style: textTheme.titleMedium), + const SizedBox(height: 12), + Row( + children: [ + RepaintBoundary(child: UrlIcon([url], iconSize: 32)), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + UriBreadcrumb( + uri: url, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart index 13e96d58..2b6ebfe4 100644 --- a/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart +++ b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart @@ -21,15 +21,28 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart'; +/// Shows install bottom sheet for sites with a valid PWA manifest (existing flow). Future showPwaInstallDialog(BuildContext context, WidgetRef ref) async { + final selectedTabId = ref.read(selectedTabProvider); final manifest = ref.read(currentTabManifestProvider); final name = manifest?.name ?? manifest?.shortName ?? 'this web app'; + final tabState = selectedTabId != null + ? ref.read(tabStateProvider(selectedTabId)) + : null; + final url = tabState?.url ?? Uri.parse('about:blank'); - final confirmed = await showPwaInstallConfirmDialog(context, name); + final confirmed = await showPwaInstallBottomSheet( + context, + name: name, + url: url, + ); if (confirmed == true) { try { @@ -60,3 +73,59 @@ Future showPwaInstallDialog(BuildContext context, WidgetRef ref) async { } } } + +/// Shows choice dialog for sites without a manifest. +/// Offers "Add as Shortcut" (always) and "Add as App" (if setting enabled). +Future showShortcutInstallDialog( + BuildContext context, + WidgetRef ref, +) async { + final selectedTabId = ref.read(selectedTabProvider); + if (selectedTabId == null) return; + + final tabState = ref.read(tabStateProvider(selectedTabId)); + final name = tabState?.title ?? 'this site'; + final url = tabState?.url ?? Uri.parse('about:blank'); + + final settings = ref.read(generalSettingsWithDefaultsProvider); + final showAppOption = settings.allowNonManifestPwaInstall; + + final choice = await showShortcutChoiceBottomSheet( + context, + name: name, + url: url, + showAppOption: showAppOption, + ); + + if (choice == null) return; + + try { + final bool success; + switch (choice) { + case ShortcutInstallType.shortcut: + success = await ref.read(installBasicShortcutProvider().future); + case ShortcutInstallType.app: + success = await ref.read(installCurrentWebAppProvider.future); + } + + if (context.mounted) { + if (success) { + showInfoMessage(context, '$name added to home screen'); + } else { + showErrorMessage(context, 'Failed to add $name to home screen'); + } + } + } catch (e, stackTrace) { + logger.e('Failed to create shortcut', error: e, stackTrace: stackTrace); + + if (context.mounted) { + var errorMessage = 'Failed to add $name to home screen'; + + if (e is StateError) { + errorMessage = 'No tab selected. Please try again.'; + } + + showErrorMessage(context, errorMessage); + } + } +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart index 86465018..00aafbce 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -196,4 +196,12 @@ class ContainerDao extends DatabaseAccessor Selectable containersToClearOnExit() { return db.definitionsDrift.containersToClearOnExit(); } + + SingleOrNullSelectable getContainerByContextualIdentity( + String contextId, + ) { + return db.definitionsDrift.containerByContextualIdentity( + contextId: contextId, + ); + } } diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift index 8949ceda..768b0336 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -335,6 +335,11 @@ allAssignedSites WITH SiteAssignment: CROSS JOIN json_each(container.metadata, '$.assignedSites') WHERE value IS NOT NULL; +containerByContextualIdentity: + SELECT * FROM container + WHERE container.metadata ->> '$.contextualIdentity' = :contextId + LIMIT 1; + containersToClearOnExit: SELECT container.metadata ->> '$.contextualIdentity' AS contextual_identity FROM container diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index f5287f2f..867e1ea1 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -2601,6 +2601,16 @@ class DefinitionsDrift extends i9.ModularAccessor { ); } + i0.Selectable containerByContextualIdentity({ + required String contextId, + }) { + return customSelect( + 'SELECT * FROM container WHERE container.metadata ->> \'\$.contextualIdentity\' = ?1 LIMIT 1', + variables: [i0.Variable(contextId)], + readsFrom: {container}, + ).asyncMap(container.mapFromRow); + } + i0.Selectable containersToClearOnExit() { return customSelect( 'SELECT container.metadata ->> \'\$.contextualIdentity\' AS contextual_identity FROM container WHERE json_extract(container.metadata, \'\$.clearDataOnExit\') = 1 AND container.metadata ->> \'\$.contextualIdentity\' IS NOT NULL', diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart index 8adacfe7..f884fce5 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -77,6 +77,14 @@ class ContainerRepository extends _$ContainerRepository { .getSingleOrNull(); } + Future getContainerByContextualIdentity(String contextId) { + return ref + .read(tabDatabaseProvider) + .containerDao + .getContainerByContextualIdentity(contextId) + .getSingleOrNull(); + } + Future> getContainerTabIds(String? id) { return ref .read(tabDatabaseProvider) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index 905edbac..39e3d00c 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f'; + r'57ee339087a845e54dc79224e25fa0da19568a68'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/app/lib/features/settings/presentation/screens/browsing_settings.dart b/app/lib/features/settings/presentation/screens/browsing_settings.dart index 9e57fdce..bd36d150 100644 --- a/app/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/app/lib/features/settings/presentation/screens/browsing_settings.dart @@ -48,6 +48,7 @@ class BrowsingSettingsScreen extends StatelessWidget { children: const [ _TabsSection(), _NavigationSection(), + _HomeScreenSection(), _ExternalLinksSection(), ], ); @@ -665,6 +666,49 @@ class _DoubleBackCloseTabTile extends HookConsumerWidget { } } +class _HomeScreenSection extends StatelessWidget { + const _HomeScreenSection(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + SettingSection(name: 'Home Screen'), + _AllowNonManifestPwaInstallTile(), + ], + ); + } +} + +class _AllowNonManifestPwaInstallTile extends HookConsumerWidget { + const _AllowNonManifestPwaInstallTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final allowNonManifestPwaInstall = ref.watch( + generalSettingsWithDefaultsProvider + .select((s) => s.allowNonManifestPwaInstall), + ); + + return SwitchListTile.adaptive( + title: const Text('Install Sites as Apps'), + subtitle: const Text( + 'Allow installing websites without a PWA manifest as standalone apps', + ), + secondary: const Icon(Icons.add_to_home_screen), + value: allowNonManifestPwaInstall, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.allowNonManifestPwaInstall(value), + ); + }, + ); + } +} + class _UrlCleanerSettingsTile extends StatelessWidget { const _UrlCleanerSettingsTile(); diff --git a/app/lib/features/share_intent/domain/entities/shared_content.dart b/app/lib/features/share_intent/domain/entities/shared_content.dart index 5d25d345..3c5465dc 100644 --- a/app/lib/features/share_intent/domain/entities/shared_content.dart +++ b/app/lib/features/share_intent/domain/entities/shared_content.dart @@ -21,37 +21,42 @@ import 'package:fast_equatable/fast_equatable.dart'; import 'package:weblibre/utils/input_classification.dart'; sealed class SharedContent with FastEquatable { - SharedContent(); + final String? contextId; - factory SharedContent.parse(String content) { + SharedContent({this.contextId}); + + factory SharedContent.parse(String content, {String? contextId}) { if (parseSharedIntentUrl(content) case final Uri uri) { - return SharedUrl(uri); + return SharedUrl(uri, contextId: contextId); } else { - return SharedText(content); + return SharedText(content, contextId: contextId); } } + + @override + List get hashParameters => [contextId]; } final class SharedUrl extends SharedContent { final Uri url; - SharedUrl(this.url); + SharedUrl(this.url, {super.contextId}); @override String toString() => url.toString(); @override - List get hashParameters => [url]; + List get hashParameters => [url, ...super.hashParameters]; } final class SharedText extends SharedContent { final String text; - SharedText(this.text); + SharedText(this.text, {super.contextId}); @override String toString() => text; @override - List get hashParameters => [text]; + List get hashParameters => [text, ...super.hashParameters]; } diff --git a/app/lib/features/share_intent/domain/services/sharing_intent.dart b/app/lib/features/share_intent/domain/services/sharing_intent.dart index 408a5df1..6afb1c7c 100644 --- a/app/lib/features/share_intent/domain/services/sharing_intent.dart +++ b/app/lib/features/share_intent/domain/services/sharing_intent.dart @@ -45,6 +45,12 @@ final _sharingIntentTransformer = _ => null, }; + // Extract container context from shortcut intents + final contextId = + intent.action == 'android.intent.action.VIEW' + ? intent.extra['pwa_context_id'] as String? + : null; + if (data != null) { if (uri_to_file.isUriSupported(data)) { var path = data; @@ -70,17 +76,23 @@ final _sharingIntentTransformer = final mimeType = mime.lookupMimeType(file.path); switch (mimeType) { case 'application/pdf': - sink.add(ReceivedIntentParameter(path, null)); + sink.add( + ReceivedIntentParameter(path, null, contextId: contextId), + ); default: logger.w('Unhandled mime type: $mimeType'); } } catch (e) { logger.e('Failed to convert URI to file: $e'); // Fallback: pass the original URI - sink.add(ReceivedIntentParameter(data, null)); + sink.add( + ReceivedIntentParameter(data, null, contextId: contextId), + ); } } else { - sink.add(ReceivedIntentParameter(data, null)); + sink.add( + ReceivedIntentParameter(data, null, contextId: contextId), + ); } } }, diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index 6d04452a..c82e4fa0 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -118,6 +118,7 @@ class GeneralSettings with FastEquatable { final bool tabBarLongPressUrlCopy; final bool unshortenerEnabled; final String unshortenerToken; + final bool allowNonManifestPwaInstall; GeneralSettings({ required this.themeMode, @@ -168,6 +169,7 @@ class GeneralSettings with FastEquatable { required this.tabBarLongPressUrlCopy, required this.unshortenerEnabled, required this.unshortenerToken, + required this.allowNonManifestPwaInstall, }); GeneralSettings.withDefaults({ @@ -219,6 +221,7 @@ class GeneralSettings with FastEquatable { bool? tabBarLongPressUrlCopy, bool? unshortenerEnabled, String? unshortenerToken, + bool? allowNonManifestPwaInstall, }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, disableAnimations = disableAnimations ?? false, @@ -276,7 +279,8 @@ class GeneralSettings with FastEquatable { smallWebTabType = smallWebTabType ?? TabType.private, tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true, unshortenerEnabled = unshortenerEnabled ?? false, - unshortenerToken = unshortenerToken ?? ''; + unshortenerToken = unshortenerToken ?? '', + allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false; factory GeneralSettings.fromJson(Map json) => _$GeneralSettingsFromJson(json); @@ -348,5 +352,6 @@ class GeneralSettings with FastEquatable { tabBarLongPressUrlCopy, unshortenerEnabled, unshortenerToken, + allowNonManifestPwaInstall, ]; } diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 4f872d9b..152d8e95 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -121,6 +121,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings unshortenerToken(String unshortenerToken); + GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -177,6 +179,7 @@ abstract class _$GeneralSettingsCWProxy { bool tabBarLongPressUrlCopy, bool unshortenerEnabled, String unshortenerToken, + bool allowNonManifestPwaInstall, }); } @@ -391,6 +394,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings unshortenerToken(String unshortenerToken) => call(unshortenerToken: unshortenerToken); + @override + GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) => + call(allowNonManifestPwaInstall: allowNonManifestPwaInstall); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -449,6 +456,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? tabBarLongPressUrlCopy = const $CopyWithPlaceholder(), Object? unshortenerEnabled = const $CopyWithPlaceholder(), Object? unshortenerToken = const $CopyWithPlaceholder(), + Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -733,6 +741,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.unshortenerToken // ignore: cast_nullable_to_non_nullable : unshortenerToken as String, + allowNonManifestPwaInstall: + allowNonManifestPwaInstall == const $CopyWithPlaceholder() || + allowNonManifestPwaInstall == null + ? _value.allowNonManifestPwaInstall + // ignore: cast_nullable_to_non_nullable + : allowNonManifestPwaInstall as bool, ); } } @@ -843,6 +857,7 @@ GeneralSettings _$GeneralSettingsFromJson( tabBarLongPressUrlCopy: json['tabBarLongPressUrlCopy'] as bool?, unshortenerEnabled: json['unshortenerEnabled'] as bool?, unshortenerToken: json['unshortenerToken'] as String?, + allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?, ); Map _$GeneralSettingsToJson( @@ -907,6 +922,7 @@ Map _$GeneralSettingsToJson( 'tabBarLongPressUrlCopy': instance.tabBarLongPressUrlCopy, 'unshortenerEnabled': instance.unshortenerEnabled, 'unshortenerToken': instance.unshortenerToken, + 'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall, }; const _$ThemeModeEnumMap = { diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index f8fd8104..25394332 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -229,6 +229,8 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.string, db.typeMapping, ), + 'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall'] + ?.readAs(DriftSqlType.bool, db.typeMapping), }); } diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index f7505c75..38fdab90 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'ae071b136a14d1f907635579f134608023d81766'; + r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt index 07054073..14c30095 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt @@ -309,7 +309,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url -> components.core.webAppManifestStorage.getManifestCache(url) - } + } ?: customTab.content.webAppManifest windowFeature.set( feature = CustomTabWindowFeature(activity, store, sessionId), diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt index 2be1187f..05f21b1d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt @@ -15,6 +15,11 @@ object PwaConstants { const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id" const val EXTRA_PWA_TOKEN = "pwa_token" const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url" + const val EXTRA_SHORTCUT_TYPE = "shortcut_type" + + // Shortcut type values + const val SHORTCUT_TYPE_BASIC = "basic" + const val SHORTCUT_TYPE_PWA = "pwa" // Profile and file paths const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile" diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt index bbad82fe..d0fab34c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt @@ -89,8 +89,16 @@ class IntentReceiverActivity : Activity() { val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID) val token = intent.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN) + val shortcutType = intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) if (profileUuid != null) { if (isTrustedPwaLaunch(intent, profileUuid, token)) { + // Basic shortcuts open in the regular browser, not as standalone PWA + if (shortcutType == PwaConstants.SHORTCUT_TYPE_BASIC) { + Log.d(TAG, "Trusted basic shortcut, routing to regular browser: ${intent.dataString}") + handleBasicShortcutIntent(intent, profileUuid) + return + } + Log.d(TAG, "Trusted PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId") handlePwaIntent(intent, profileUuid, contextId) return @@ -273,7 +281,11 @@ class IntentReceiverActivity : Activity() { if (currentProfileUuid != null && currentProfileUuid != profileUuid) { Log.d(TAG, "Profile mismatch: current=$currentProfileUuid, expected=$profileUuid") - showProfileMismatchDialog(url, contextId) + showProfileMismatchDialog( + intent = intent, + onProceed = { launchPwaWithContext(url, contextId) }, + isPwa = true, + ) } else { Log.d(TAG, "Profile match or indeterminate, launching PWA with contextId=$contextId") launchPwaWithContext(url, contextId) @@ -300,26 +312,48 @@ class IntentReceiverActivity : Activity() { } /** - * Shows a dialog when the current profile doesn't match the PWA's installation profile. + * Handles basic shortcut intents with profile validation. + * Checks profile match and shows dialog if different, then forwards to regular browser. + */ + private fun handleBasicShortcutIntent(intent: Intent, profileUuid: String) { + val currentProfileUuid = getCurrentProfileUuid() + + if (currentProfileUuid != null && currentProfileUuid != profileUuid) { + Log.d(TAG, "Basic shortcut profile mismatch: current=$currentProfileUuid, expected=$profileUuid") + showProfileMismatchDialog( + intent = intent, + onProceed = { handleRegularIntent(it) }, + isPwa = false, + ) + } else { + Log.d(TAG, "Basic shortcut profile match or indeterminate, routing to browser") + handleRegularIntent(intent) + } + } + + /** + * Shows a dialog when the current profile doesn't match the shortcut's installation profile. */ private fun showProfileMismatchDialog( - url: String, - contextId: String?, + intent: Intent, + onProceed: (Intent) -> Unit, + isPwa: Boolean, ) { - val message = "This PWA was originally installed in a different profile. " + + val typeLabel = if (isPwa) "PWA" else "shortcut" + val message = "This $typeLabel was originally installed in a different profile. " + "Opening it here uses only your current profile's data and settings. " + "The original profile's app state and saved data will not be used.\n\n" + "Do you want to proceed anyway?" AlertDialog.Builder(this) - .setTitle("PWA Profile Mismatch") + .setTitle("Profile Mismatch") .setMessage(message) .setPositiveButton("Open in Current Profile") { _, _ -> - Log.d(TAG, "User chose to open PWA despite profile mismatch") - launchPwaWithContext(url, contextId) + Log.d(TAG, "User chose to open $typeLabel despite profile mismatch") + onProceed(intent) } .setNegativeButton("Cancel") { _, _ -> - Log.d(TAG, "User cancelled PWA launch due to profile mismatch") + Log.d(TAG, "User cancelled $typeLabel launch due to profile mismatch") finish() } .setOnCancelListener { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt index a1f6c899..e019efd6 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt @@ -86,11 +86,17 @@ class GeckoPwaApiImpl( return@launch } - val manifest = tab.content.webAppManifest - if (manifest == null) { - logger.warn("No manifest found for tab ${tab.id}") - callback(Result.success(false)) - return@launch + val manifest = tab.content.webAppManifest ?: run { + // Generate a synthetic manifest for sites without one + val url = tab.content.url + val title = tab.content.title.ifBlank { url } + logger.debug("Generating synthetic manifest for tab ${tab.id}: $url") + WebAppManifest( + name = title, + startUrl = url, + display = WebAppManifest.DisplayMode.STANDALONE, + scope = extractScope(url), + ) } logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}") @@ -161,6 +167,7 @@ class GeckoPwaApiImpl( putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId) putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken) putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl) + putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_PWA) } val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { @@ -179,6 +186,11 @@ class GeckoPwaApiImpl( } }.build() + // Update existing shortcut intent if one exists with the same ID + // (e.g. upgrading a basic shortcut to PWA). requestPinShortcut alone + // may reuse the cached intent on some launchers. + updateExistingShortcut(shortcutManager, shortcut) + val success = shortcutManager.requestPinShortcut(shortcut, null) logger.debug("PWA shortcut creation result: $success") success @@ -188,6 +200,23 @@ class GeckoPwaApiImpl( } } + /** + * Updates an existing pinned/cached shortcut's intent and metadata. + * This is necessary because requestPinShortcut may reuse the old cached intent + * on some launchers instead of the new ShortcutInfo's intent. + */ + private fun updateExistingShortcut(shortcutManager: ShortcutManager, shortcut: ShortcutInfo) { + try { + val existingIds = shortcutManager.pinnedShortcuts.map { it.id }.toSet() + if (shortcut.id in existingIds) { + shortcutManager.updateShortcuts(listOf(shortcut)) + logger.debug("Updated existing pinned shortcut: ${shortcut.id}") + } + } catch (e: Exception) { + logger.debug("Could not update existing shortcut (may not exist): ${e.message}") + } + } + /** * Generates a collision-resistant shortcut ID from URL + profile using SHA-256. */ @@ -239,6 +268,158 @@ class GeckoPwaApiImpl( } } + override fun installBasicShortcut( + tabId: String?, + profileUuid: String, + contextId: String?, + overrideShortcutName: String?, + callback: (Result) -> Unit + ) { + logger.debug("installBasicShortcut called for tabId: $tabId, profileUuid: $profileUuid") + coroutineScope.launch { + try { + val store = components.core.store + val tab = if (tabId != null) { + store.state.findTab(tabId) + } else { + store.state.selectedTab + } + + if (tab == null) { + logger.warn("Tab not found for installBasicShortcut: $tabId") + callback(Result.success(false)) + return@launch + } + + val success = createBasicShortcut( + url = tab.content.url, + title = overrideShortcutName ?: tab.content.title, + tabIcon = tab.content.icon, + profileUuid = profileUuid, + contextId = contextId, + ) + + callback(Result.success(success)) + } catch (e: Exception) { + logger.error("Failed to create basic shortcut", e) + callback(Result.failure(e)) + } + } + } + + /** + * Creates a basic bookmark-style shortcut that opens in a regular browser tab. + * Does not require or store a manifest. + */ + private suspend fun createBasicShortcut( + url: String, + title: String, + tabIcon: Bitmap?, + profileUuid: String, + contextId: String?, + ): Boolean = withContext(Dispatchers.Main) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + logger.warn("Pinned shortcuts require Android O or later") + return@withContext false + } + + val shortcutManager = context.getSystemService() + ?: run { + logger.error("ShortcutManager not available") + return@withContext false + } + + if (!shortcutManager.isRequestPinShortcutSupported) { + logger.warn("Pinning shortcuts is not supported") + return@withContext false + } + + val shortcutId = generateShortcutId(url, profileUuid) + val launchToken = resolveLaunchToken( + shortcutManager = shortcutManager, + shortcutId = shortcutId, + startUrl = url, + profileUuid = profileUuid, + ) + + val shortLabel = title.ifBlank { url } + + val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(url) + putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid) + putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId) + putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken) + putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, url) + putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_BASIC) + } + + val icon = loadTabIcon(url, tabIcon) + + val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { + setShortLabel(shortLabel) + setLongLabel(shortLabel) + setIntent(shortcutIntent) + icon?.let { setIcon(it) } + }.build() + + // Update existing shortcut intent if one exists with the same ID + updateExistingShortcut(shortcutManager, shortcut) + + val success = shortcutManager.requestPinShortcut(shortcut, null) + logger.debug("Basic shortcut creation result: $success") + success + } catch (e: Exception) { + logger.error("Failed to create basic shortcut", e) + false + } + } + + /** + * Loads an icon for the shortcut from the tab's favicon or BrowserIcons. + */ + private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) { + try { + // Try using the tab's existing favicon first + val bitmap = tabIcon?.takeUnless { it.isRecycled } + ?: run { + // Fall back to loading via BrowserIcons + val iconRequest = IconRequest( + url = url, + size = IconRequest.Size.LAUNCHER, + ) + components.core.icons.loadIcon(iconRequest).await()?.bitmap + } + + bitmap?.takeUnless { it.isRecycled }?.let { + val bitmapCopy = it.copy(it.config ?: Bitmap.Config.ARGB_8888, false) + Icon.createWithBitmap(bitmapCopy) + } + } catch (e: Exception) { + logger.error("Failed to load tab icon", e) + null + } + } + + /** + * Extracts the scope from a URL (origin + path up to last segment). + */ + private fun extractScope(url: String): String { + return try { + val uri = Uri.parse(url) + val path = uri.path ?: "/" + val scopePath = if (path.contains("/")) { + path.substringBeforeLast("/") + "/" + } else { + "/" + } + uri.buildUpon().path(scopePath).clearQuery().fragment(null).build().toString() + } catch (e: Exception) { + url + } + } + override fun getInstalledWebApps(callback: (Result>) -> Unit) { logger.debug("getInstalledWebApps called") coroutineScope.launch { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index fc798972..5dacc0a2 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -8943,6 +8943,20 @@ interface GeckoPwaApi { fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result) -> Unit) /** Returns a list of all installed PWA manifests. */ fun getInstalledWebApps(callback: (Result>) -> Unit) + /** + * Creates a basic bookmark shortcut on the home screen (no manifest required). + * + * Unlike [installWebApp], this creates a simple shortcut that opens + * in a regular browser tab rather than standalone PWA mode. + * Uses the page title and favicon for the shortcut. + * + * The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab. + * The [profileUuid] is the UUID of the current user profile. + * The [contextId] is the container's contextual identity (optional). + * The [overrideShortcutName] allows customizing the shortcut label. + * Returns true if the shortcut was created successfully. + */ + fun installBasicShortcut(tabId: String?, profileUuid: String, contextId: String?, overrideShortcutName: String?, callback: (Result) -> Unit) companion object { /** The codec used by GeckoPwaApi. */ @@ -8993,6 +9007,29 @@ interface GeckoPwaApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installBasicShortcut$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val profileUuidArg = args[1] as String + val contextIdArg = args[2] as String? + val overrideShortcutNameArg = args[3] as String? + api.installBasicShortcut(tabIdArg, profileUuidArg, contextIdArg, overrideShortcutNameArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 7371683f..af8e2009 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -10665,4 +10665,42 @@ class GeckoPwaApi { return (pigeonVar_replyList[0] as List?)!.cast(); } } + + /// Creates a basic bookmark shortcut on the home screen (no manifest required). + /// + /// Unlike [installWebApp], this creates a simple shortcut that opens + /// in a regular browser tab rather than standalone PWA mode. + /// Uses the page title and favicon for the shortcut. + /// + /// The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab. + /// The [profileUuid] is the UUID of the current user profile. + /// The [contextId] is the container's contextual identity (optional). + /// The [overrideShortcutName] allows customizing the shortcut label. + /// Returns true if the shortcut was created successfully. + Future installBasicShortcut(String? tabId, String profileUuid, String? contextId, String? overrideShortcutName) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installBasicShortcut$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tabId, profileUuid, contextId, overrideShortcutName]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } } diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index ad09b330..2e6dfd3f 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -2473,4 +2473,18 @@ abstract class GeckoPwaApi { /// Returns a list of all installed PWA manifests. @async List getInstalledWebApps(); + + /// Creates a basic bookmark shortcut on the home screen (no manifest required). + /// + /// Unlike [installWebApp], this creates a simple shortcut that opens + /// in a regular browser tab rather than standalone PWA mode. + /// Uses the page title and favicon for the shortcut. + /// + /// The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab. + /// The [profileUuid] is the UUID of the current user profile. + /// The [contextId] is the container's contextual identity (optional). + /// The [overrideShortcutName] allows customizing the shortcut label. + /// Returns true if the shortcut was created successfully. + @async + bool installBasicShortcut(String? tabId, String profileUuid, String? contextId, String? overrideShortcutName); }