diff --git a/apps/weblibre/lib/core/routing/routes.browser.dart b/apps/weblibre/lib/core/routing/routes.browser.dart index 4adb94f7..e7040a00 100644 --- a/apps/weblibre/lib/core/routing/routes.browser.dart +++ b/apps/weblibre/lib/core/routing/routes.browser.dart @@ -250,14 +250,25 @@ class TabTreeRoute extends GoRouteData with $TabTreeRoute { class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute { final String sharedUrl; + final String? contextId; + final String? containerMode; - const OpenSharedContentRoute({this.sharedUrl = 'about:blank'}); + const OpenSharedContentRoute({ + this.sharedUrl = 'about:blank', + this.contextId, + this.containerMode, + }); @override Page buildPage(BuildContext context, GoRouterState state) { return BottomSheetPage( builder: (_) => OpenSharedContent( sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'), + contextId: contextId, + containerMode: IntentContainerMode.fromWireValue( + containerMode, + contextId: contextId, + ), ), ); } diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index c5cb4707..8bc0d609 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -81,6 +81,7 @@ import 'package:weblibre/features/settings/presentation/screens/tracking_protect import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart'; import 'package:weblibre/features/tor/presentation/screens/country_picker.dart'; import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index 8fa0c258..5f185307 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -860,6 +860,8 @@ mixin $OpenSharedContentRoute on GoRouteData { static OpenSharedContentRoute _fromState(GoRouterState state) => OpenSharedContentRoute( sharedUrl: state.uri.queryParameters['shared-url'] ?? 'about:blank', + contextId: state.uri.queryParameters['context-id'], + containerMode: state.uri.queryParameters['container-mode'], ); OpenSharedContentRoute get _self => this as OpenSharedContentRoute; @@ -869,6 +871,8 @@ mixin $OpenSharedContentRoute on GoRouteData { '/browser/open_content', queryParams: { if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl, + if (_self.contextId != null) 'context-id': _self.contextId, + if (_self.containerMode != null) 'container-mode': _self.containerMode, }, ); diff --git a/apps/weblibre/lib/data/models/received_intent_parameter.dart b/apps/weblibre/lib/data/models/received_intent_parameter.dart index af5905a0..e9300c91 100644 --- a/apps/weblibre/lib/data/models/received_intent_parameter.dart +++ b/apps/weblibre/lib/data/models/received_intent_parameter.dart @@ -18,14 +18,21 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; class ReceivedIntentParameter with FastEquatable { final String? content; final String? tool; final String? contextId; + final IntentContainerMode containerMode; - ReceivedIntentParameter(this.content, this.tool, {this.contextId}); + ReceivedIntentParameter( + this.content, + this.tool, { + this.contextId, + this.containerMode = IntentContainerMode.useSelected, + }); @override - List get hashParameters => [content, tool, contextId]; + List get hashParameters => [content, tool, contextId, containerMode]; } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers/intent.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers/intent.dart index c9a9c4db..54df0350 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers/intent.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers/intent.dart @@ -35,8 +35,11 @@ final _contentParserTransformer = StreamTransformer.fromHandlers( handleData: (parameter, sink) { final parsed = parameter.content.mapNotNull( - (content) => - SharedContent.parse(content, contextId: parameter.contextId), + (content) => SharedContent.parse( + content, + contextId: parameter.contextId, + containerMode: parameter.containerMode, + ), ); if (parsed != null) { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 4d939b22..6a3a496e 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -58,6 +58,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_inte import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart'; import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; @@ -403,6 +404,7 @@ class _BrowserViewState extends ConsumerState final containerSelection = await _resolveContainerSelection( ref, sharedContent.contextId, + sharedContent.containerMode, ); await ref @@ -443,6 +445,8 @@ class _BrowserViewState extends ConsumerState case SharedUrl(): final route = OpenSharedContentRoute( sharedUrl: sharedContent.url.toString(), + contextId: sharedContent.contextId, + containerMode: sharedContent.containerMode.queryValueOrNull, ); await router.push(route.location); case SharedText(): @@ -704,13 +708,19 @@ class _BrowserViewState extends ConsumerState } } -/// Resolves a [TabContainerSelection] from a shortcut intent's context ID. -/// Returns [TabContainerSelection.useSelected] if no contextId or container not found. +/// Resolves a [TabContainerSelection] from incoming launch container metadata. Future _resolveContainerSelection( WidgetRef ref, String? contextId, + IntentContainerMode containerMode, ) async { - if (contextId == null) return const TabContainerSelection.useSelected(); + if (contextId == null) { + return switch (containerMode) { + IntentContainerMode.unassigned => + const TabContainerSelection.unassigned(), + _ => const TabContainerSelection.useSelected(), + }; + } final container = await ref .read(containerRepositoryProvider.notifier) @@ -720,5 +730,9 @@ Future _resolveContainerSelection( return TabContainerSelection.specific(container); } - return const TabContainerSelection.useSelected(); + return switch (containerMode) { + IntentContainerMode.useSelected => + const TabContainerSelection.useSelected(), + _ => const TabContainerSelection.unassigned(), + }; } diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 7a395f5b..66c7fd89 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -27,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; +import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; @@ -35,22 +36,34 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/debouncer.dart'; -import 'package:weblibre/presentation/icons/weblibre_icons.dart'; import 'package:weblibre/utils/form_validators.dart'; import 'package:weblibre/utils/ui_helper.dart'; class OpenSharedContent extends HookConsumerWidget { final Uri sharedUrl; + final String? contextId; + final IntentContainerMode containerMode; static final _appLinksService = GeckoAppLinksService(); - const OpenSharedContent({super.key, required this.sharedUrl}); + const OpenSharedContent({ + super.key, + required this.sharedUrl, + this.contextId, + this.containerMode = IntentContainerMode.useSelected, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -58,7 +71,81 @@ class OpenSharedContent extends HookConsumerWidget { final textController = useTextEditingController(text: sharedUrl.toString()); final appColors = AppColors.of(context); - final selectedContainer = useState(null); + final globalSelectedContainer = ref.watch( + selectedContainerDataProvider.select((value) => value.value), + ); + final selectedContainer = useState( + containerMode == IntentContainerMode.useSelected + ? globalSelectedContainer + : null, + ); + final containerSelectionTouched = useRef(false); + final defaultTabType = ref.read( + generalSettingsWithDefaultsProvider.select( + (value) => value.effectiveDefaultCreateTabType, + ), + ); + + // If the intent carried an isolated context, preselect the isolated tab + // type and preserve the existing context id so opening doesn't mint a + // fresh one. + final carriedIsolatedContextId = useMemoized( + () => isIsolatedContextId(contextId) ? contextId : null, + [contextId], + ); + + final selectedTabType = useState( + carriedIsolatedContextId != null ? TabType.isolated : defaultTabType, + ); + + useEffect(() { + if (!containerSelectionTouched.value && + containerMode == IntentContainerMode.useSelected) { + selectedContainer.value = globalSelectedContainer; + } + return null; + }, [globalSelectedContainer, containerMode]); + + useEffect(() { + if (containerMode != IntentContainerMode.specific || + contextId == null || + isIsolatedContextId(contextId)) { + if (!containerSelectionTouched.value && + containerMode == IntentContainerMode.unassigned) { + selectedContainer.value = null; + } + return null; + } + + var cancelled = false; + + unawaited( + Future(() async { + final container = await ref + .read(containerRepositoryProvider.notifier) + .getContainerByContextualIdentity(contextId!); + if (cancelled || + !context.mounted || + containerSelectionTouched.value) { + return; + } + + selectedContainer.value = container; + }), + ); + + return () { + cancelled = true; + }; + }, [containerMode, contextId]); + + TabMode resolveTabMode() { + if (selectedTabType.value == TabType.isolated && + carriedIsolatedContextId != null) { + return TabMode.isolated(carriedIsolatedContextId); + } + return TabMode.fromTabType(selectedTabType.value); + } final settings = ref.watch(generalSettingsWithDefaultsProvider); final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); @@ -151,7 +238,7 @@ class OpenSharedContent extends HookConsumerWidget { } } - Future openCustomTab(bool isPrivate) async { + Future openCustomTab(TabMode tabMode) async { if (formKey.currentState?.validate() == true) { final parsedUrl = parseValidatedUrl( textController.text, @@ -161,10 +248,14 @@ class OpenSharedContent extends HookConsumerWidget { return; } + final contextId = tabMode is IsolatedTabMode + ? tabMode.isolationContextId + : selectedContainer.value?.metadata.contextualIdentity; + await GeckoBrowserService().openInCustomTab( url: parsedUrl, - private: isPrivate, - contextId: selectedContainer.value?.id, + private: tabMode is PrivateTabMode, + contextId: contextId, ); if (context.mounted) { @@ -203,18 +294,7 @@ class OpenSharedContent extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Open link', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), - if (settings.showContainerUi) - ContainerChips( - displayMenu: false, - selectedContainer: selectedContainer.value, - onSelected: (container) { - selectedContainer.value = container; - }, - onDeleted: (container) { - selectedContainer.value = null; - }, - ), + const SizedBox(height: 8), TextFormField( controller: textController, keyboardType: TextInputType.url, @@ -337,64 +417,78 @@ class OpenSharedContent extends HookConsumerWidget { icon: Icons.open_in_new, onTap: openInApp, ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Builder( + builder: (context) { + final tabTypeSwitcher = AnimatedTabTypeSwitcher( + selected: selectedTabType.value, + onChanged: (value) => selectedTabType.value = value, + showIsolatedOption: + settings.showIsolatedTabUi || + carriedIsolatedContextId != null, + selectedBackgroundColor: switch (selectedTabType.value) { + TabType.regular => null, + TabType.private => appColors.privateSelectionOverlay, + TabType.isolated => appColors.isolatedSelectionOverlay, + TabType.child => null, + }, + ); + + if (!settings.showContainerUi) { + return Center(child: tabTypeSwitcher); + } + + return Row( + children: [ + Expanded( + flex: 4, + child: Align( + alignment: Alignment.centerLeft, + child: tabTypeSwitcher, + ), + ), + const SizedBox(width: 8), + Flexible( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: CompactContainerSelector( + selectedContainer: selectedContainer.value, + onSelectionChanged: (selection) async { + containerSelectionTouched.value = true; + switch (selection) { + case ContainerSelectionSelected( + :final containerId, + ): + selectedContainer.value = await ref + .read( + containerRepositoryProvider.notifier, + ) + .getContainerData(containerId); + case ContainerSelectionUnassigned(): + selectedContainer.value = null; + } + }, + ), + ), + ), + ], + ); + }, + ), + ), _OpenActionTile( title: 'Open in new tab', subtitle: 'Add to your browser tabs', icon: MdiIcons.tab, - trailing: PopupMenuButton( - icon: const Icon(WebLibreIcons.tabType, size: 24), - tooltip: settings.showIsolatedTabUi - ? 'Private / Isolated' - : 'Private', - onSelected: openTab, - itemBuilder: (context) => [ - PopupMenuItem( - value: TabMode.private, - child: Row( - children: [ - Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, - size: 20, - ), - const SizedBox(width: 12), - const Text('Private'), - ], - ), - ), - if (settings.showIsolatedTabUi) - PopupMenuItem( - value: TabMode.newIsolated(), - child: Row( - children: [ - Icon( - MdiIcons.snowflake, - color: appColors.isolatedTabTeal, - size: 20, - ), - const SizedBox(width: 12), - const Text('Isolated'), - ], - ), - ), - ], - ), - onTap: () => openTab(TabMode.regular), + onTap: () => openTab(resolveTabMode()), ), _OpenActionTile( title: 'Open in custom tab', subtitle: 'Open in a separate window', icon: MdiIcons.applicationOutline, - trailing: IconButton( - icon: Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, - size: 24, - ), - tooltip: 'Private', - onPressed: () => openCustomTab(true), - ), - onTap: () => openCustomTab(false), + onTap: () => openCustomTab(resolveTabMode()), ), ], ), @@ -491,7 +585,7 @@ class _OpenActionTile extends StatelessWidget { final IconData icon; final Widget? trailing; final bool showTrailingDivider; - final VoidCallback onTap; + final VoidCallback? onTap; const _OpenActionTile({ required this.title, @@ -499,7 +593,7 @@ class _OpenActionTile extends StatelessWidget { required this.icon, this.trailing, this.showTrailingDivider = true, - required this.onTap, + this.onTap, }); @override diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart index 66b00fc0..029eb4c1 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart @@ -29,8 +29,6 @@ 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'; part 'providers.g.dart'; @@ -95,7 +93,11 @@ bool isCurrentTabInstallable(Ref ref) { /// Installs the current tab as a PWA, embedding profile and container context /// in the shortcut intent so the PWA reopens with the same isolation. @Riverpod() -Future installCurrentWebApp(Ref ref) async { +Future installCurrentWebApp( + Ref ref, { + String? overrideName, + String? contextId, +}) async { final selectedTabId = ref.read(selectedTabProvider); if (selectedTabId == null) { @@ -104,18 +106,12 @@ Future installCurrentWebApp(Ref ref) async { 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().installWebApp(selectedTabId, profileUuid, contextId); + return GeckoPwaApi().installWebApp( + selectedTabId, + profileUuid, + contextId, + overrideName, + ); } /// Returns all installed PWAs. @@ -139,7 +135,11 @@ bool isCurrentTabShortcutable(Ref ref) { /// Creates a basic bookmark shortcut on the home screen for the current tab. @Riverpod() -Future installBasicShortcut(Ref ref, {String? overrideName}) async { +Future installBasicShortcut( + Ref ref, { + String? overrideName, + String? contextId, +}) async { final selectedTabId = ref.read(selectedTabProvider); if (selectedTabId == null) { @@ -148,17 +148,6 @@ Future installBasicShortcut(Ref ref, {String? overrideName}) async { 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, diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart index 5283581a..9997807a 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.g.dart @@ -213,7 +213,7 @@ String _$isCurrentTabInstallableHash() => /// in the shortcut intent so the PWA reopens with the same isolation. @ProviderFor(installCurrentWebApp) -final installCurrentWebAppProvider = InstallCurrentWebAppProvider._(); +final installCurrentWebAppProvider = InstallCurrentWebAppFamily._(); /// Installs the current tab as a PWA, embedding profile and container context /// in the shortcut intent so the PWA reopens with the same isolation. @@ -223,20 +223,27 @@ final class InstallCurrentWebAppProvider with $FutureModifier, $FutureProvider { /// Installs the current tab as a PWA, embedding profile and container context /// in the shortcut intent so the PWA reopens with the same isolation. - InstallCurrentWebAppProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'installCurrentWebAppProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); + InstallCurrentWebAppProvider._({ + required InstallCurrentWebAppFamily super.from, + required ({String? overrideName, String? contextId}) super.argument, + }) : super( + retry: null, + name: r'installCurrentWebAppProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); @override String debugGetCreateSourceHash() => _$installCurrentWebAppHash(); + @override + String toString() { + return r'installCurrentWebAppProvider' + '' + '$argument'; + } + @$internal @override $FutureProviderElement $createElement($ProviderPointer pointer) => @@ -244,12 +251,61 @@ final class InstallCurrentWebAppProvider @override FutureOr create(Ref ref) { - return installCurrentWebApp(ref); + final argument = + this.argument as ({String? overrideName, String? contextId}); + return installCurrentWebApp( + ref, + overrideName: argument.overrideName, + contextId: argument.contextId, + ); + } + + @override + bool operator ==(Object other) { + return other is InstallCurrentWebAppProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; } } String _$installCurrentWebAppHash() => - r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f'; + r'0e0013fac7b70441983f7af1e6db3c6470c23900'; + +/// Installs the current tab as a PWA, embedding profile and container context +/// in the shortcut intent so the PWA reopens with the same isolation. + +final class InstallCurrentWebAppFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr, + ({String? overrideName, String? contextId}) + > { + InstallCurrentWebAppFamily._() + : super( + retry: null, + name: r'installCurrentWebAppProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Installs the current tab as a PWA, embedding profile and container context + /// in the shortcut intent so the PWA reopens with the same isolation. + + InstallCurrentWebAppProvider call({ + String? overrideName, + String? contextId, + }) => InstallCurrentWebAppProvider._( + argument: (overrideName: overrideName, contextId: contextId), + from: this, + ); + + @override + String toString() => r'installCurrentWebAppProvider'; +} /// Returns all installed PWAs. @@ -357,7 +413,7 @@ final class InstallBasicShortcutProvider /// Creates a basic bookmark shortcut on the home screen for the current tab. InstallBasicShortcutProvider._({ required InstallBasicShortcutFamily super.from, - required String? super.argument, + required ({String? overrideName, String? contextId}) super.argument, }) : super( retry: null, name: r'installBasicShortcutProvider', @@ -373,7 +429,7 @@ final class InstallBasicShortcutProvider String toString() { return r'installBasicShortcutProvider' '' - '($argument)'; + '$argument'; } @$internal @@ -383,8 +439,13 @@ final class InstallBasicShortcutProvider @override FutureOr create(Ref ref) { - final argument = this.argument as String?; - return installBasicShortcut(ref, overrideName: argument); + final argument = + this.argument as ({String? overrideName, String? contextId}); + return installBasicShortcut( + ref, + overrideName: argument.overrideName, + contextId: argument.contextId, + ); } @override @@ -399,12 +460,16 @@ final class InstallBasicShortcutProvider } String _$installBasicShortcutHash() => - r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2'; + r'fcb5bec79f375b32a7a0953ad22859a0db222167'; /// Creates a basic bookmark shortcut on the home screen for the current tab. final class InstallBasicShortcutFamily extends $Family - with $FunctionalFamilyOverride, String?> { + with + $FunctionalFamilyOverride< + FutureOr, + ({String? overrideName, String? contextId}) + > { InstallBasicShortcutFamily._() : super( retry: null, @@ -416,8 +481,13 @@ final class InstallBasicShortcutFamily extends $Family /// Creates a basic bookmark shortcut on the home screen for the current tab. - InstallBasicShortcutProvider call({String? overrideName}) => - InstallBasicShortcutProvider._(argument: overrideName, from: this); + InstallBasicShortcutProvider call({ + String? overrideName, + String? contextId, + }) => InstallBasicShortcutProvider._( + argument: (overrideName: overrideName, contextId: contextId), + from: this, + ); @override String toString() => r'installBasicShortcutProvider'; diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart b/apps/weblibre/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart index a0b27e63..0fd2e96d 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart @@ -18,41 +18,67 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/tabs/data/entities/isolation_context.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; /// 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, null if dismissed. -Future showPwaInstallBottomSheet( +/// Result of the install configuration sheets. +class PwaInstallConfig { + final String name; + final String? contextId; + + const PwaInstallConfig({required this.name, required this.contextId}); +} + +/// Result of the shortcut choice sheet (basic vs app + config). +class ShortcutInstallConfig { + final ShortcutInstallType type; + final String name; + final String? contextId; + + const ShortcutInstallConfig({ + required this.type, + required this.name, + required this.contextId, + }); +} + +/// Shows a bottom sheet to confirm installing a PWA with an editable name +/// and a storage (contextId) selection. +Future showPwaInstallBottomSheet( BuildContext context, { - required String name, + required String defaultName, required Uri url, }) { - return showModalBottomSheet( + return showModalBottomSheet( context: context, + isScrollControlled: true, 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), - ), - ], + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: _InstallConfigSheet( + defaultName: defaultName, + url: url, + showAppOption: true, + showShortcutOption: false, ), ), - ); + ).then((result) { + if (result == null) return null; + return PwaInstallConfig(name: result.name, contextId: result.contextId); + }); } /// Shows a bottom sheet for non-manifest sites offering a choice between @@ -60,99 +86,289 @@ Future showPwaInstallBottomSheet( /// /// [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( +Future showShortcutChoiceBottomSheet( BuildContext context, { - required String name, + required String defaultName, required Uri url, required bool showAppOption, }) { - return showModalBottomSheet( + return showModalBottomSheet( context: context, + isScrollControlled: true, 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), - ), - ], + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: _InstallConfigSheet( + defaultName: defaultName, + url: url, + showAppOption: showAppOption, + showShortcutOption: true, ), ), ); } -class _ShortcutSheetHeader extends StatelessWidget { - final String name; - final Uri url; +/// Internal storage options backing the radio selector. +sealed class _StorageOption { + const _StorageOption(); + String? get contextId; +} - const _ShortcutSheetHeader({required this.name, required this.url}); +class _StorageDefault extends _StorageOption { + const _StorageDefault(); + @override + String? get contextId => null; +} + +class _StorageContainer extends _StorageOption { + final String label; + @override + final String contextId; + const _StorageContainer({required this.label, required this.contextId}); +} + +class _StorageInheritIsolated extends _StorageOption { + @override + final String contextId; + const _StorageInheritIsolated(this.contextId); +} + +class _StorageNewIsolated extends _StorageOption { + @override + final String contextId; + _StorageNewIsolated() : contextId = newIsolatedContextId(); +} + +class _InstallConfigSheet extends HookConsumerWidget { + final String defaultName; + final Uri url; + final bool showAppOption; + final bool showShortcutOption; + + const _InstallConfigSheet({ + required this.defaultName, + required this.url, + required this.showAppOption, + required this.showShortcutOption, + }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { 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, - ), - ), - ], + final nameController = useTextEditingController(text: defaultName); + + final selectedTabId = ref.watch(selectedTabProvider); + final tabContextId = selectedTabId != null + ? ref.watch(tabStateProvider(selectedTabId))?.contextId + : null; + final containerAsync = ref.watch(selectedContainerDataProvider); + final containerData = containerAsync.asData?.value; + + final options = useMemoized>( + () { + final list = <_StorageOption>[const _StorageDefault()]; + + // If the current tab is in an isolated context, offer to inherit it. + if (isIsolatedContextId(tabContextId)) { + list.add(_StorageInheritIsolated(tabContextId!)); + } + + // If a regular (non-isolated) container is active, offer it. + final containerContextId = containerData?.metadata.contextualIdentity; + if (containerData != null && + containerContextId != null && + !isIsolatedContextId(containerContextId)) { + list.add( + _StorageContainer( + label: containerData.name ?? 'Container', + contextId: containerContextId, + ), + ); + } + + list.add(_StorageNewIsolated()); + return list; + }, + [tabContextId, containerData?.id, containerData?.metadata.contextualIdentity], + ); + + // Pick sensible default selection based on current context. + final defaultIndex = useMemoized(() { + for (var i = 0; i < options.length; i++) { + final o = options[i]; + if (o is _StorageInheritIsolated) return i; + if (o is _StorageContainer) return i; + } + return 0; + }, [options]); + + final selectedIndex = useState(defaultIndex); + final userTouched = useRef(false); + + // When options resolve async (e.g. selected container stream arrives + // after first build), re-sync the radio to the computed default — unless + // the user has already made a choice. + useEffect(() { + if (!userTouched.value) { + selectedIndex.value = defaultIndex; + } + return null; + }, [defaultIndex]); + + void submit(ShortcutInstallType type) { + final name = nameController.text.trim(); + Navigator.of(context).pop( + ShortcutInstallConfig( + type: type, + name: name.isEmpty ? defaultName : name, + contextId: options[selectedIndex.value].contextId, + ), + ); + } + + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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: UriBreadcrumb( + uri: url, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + isDense: true, + ), + textInputAction: TextInputAction.done, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + 'Storage', + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + RadioGroup( + groupValue: selectedIndex.value, + onChanged: (v) { + if (v != null) { + userTouched.value = true; + selectedIndex.value = v; + } + }, + child: Column( + children: [ + for (var i = 0; i < options.length; i++) + _StorageTile(option: options[i], value: i), + ], + ), + ), + const SizedBox(height: 8), + 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: () => submit(ShortcutInstallType.app), + ), + if (showShortcutOption) + ListTile( + leading: const Icon(Icons.shortcut), + title: const Text('Add Shortcut'), + subtitle: const Text( + 'Opens as a standard tab in the browser.', + ), + onTap: () => submit(ShortcutInstallType.shortcut), + ), + const SizedBox(height: 8), + ], + ), ), ); } } + +class _StorageTile extends StatelessWidget { + final _StorageOption option; + final int value; + + const _StorageTile({required this.option, required this.value}); + + @override + Widget build(BuildContext context) { + final (title, subtitle, icon) = switch (option) { + _StorageDefault() => ( + 'Default', + 'Uses the default browser storage (no container).', + Icons.public, + ), + _StorageContainer(:final label) => ( + 'Container "$label"', + 'Shares cookies and data with the selected container.', + Icons.folder_outlined, + ), + _StorageInheritIsolated() => ( + 'Inherit current isolated context', + 'Shares storage with the currently open isolated session.', + Icons.link, + ), + _StorageNewIsolated() => ( + 'New isolated context', + 'Creates a fresh storage jar just for this installation.', + Icons.shield_outlined, + ), + }; + + return RadioListTile( + value: value, + dense: true, + secondary: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart b/apps/weblibre/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart index 2b6ebfe4..fef33625 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart @@ -32,44 +32,52 @@ import 'package:weblibre/utils/ui_helper.dart'; 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 defaultName = + manifest?.shortName ?? manifest?.name ?? 'this web app'; final tabState = selectedTabId != null ? ref.read(tabStateProvider(selectedTabId)) : null; final url = tabState?.url ?? Uri.parse('about:blank'); - final confirmed = await showPwaInstallBottomSheet( + final config = await showPwaInstallBottomSheet( context, - name: name, + defaultName: defaultName, url: url, ); - if (confirmed == true) { - try { - final success = await ref.read(installCurrentWebAppProvider.future); + if (config == null) return; - if (context.mounted) { - if (success) { - showInfoMessage(context, '$name added to home screen'); - } else { - showErrorMessage( - context, - 'Failed to add $name. The site may not support installation.', - ); - } + final name = config.name; + + try { + final success = await ref.read( + installCurrentWebAppProvider( + overrideName: name == defaultName ? null : name, + contextId: config.contextId, + ).future, + ); + + if (context.mounted) { + if (success) { + showInfoMessage(context, '$name added to home screen'); + } else { + showErrorMessage( + context, + 'Failed to add $name. The site may not support installation.', + ); } - } catch (e, stackTrace) { - logger.e('Failed to install PWA', error: e, stackTrace: stackTrace); + } + } catch (e, stackTrace) { + logger.e('Failed to install PWA', error: e, stackTrace: stackTrace); - if (context.mounted) { - var errorMessage = 'Failed to add $name to home screen'; + 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); + if (e is StateError) { + errorMessage = 'No tab selected. Please try again.'; } + + showErrorMessage(context, errorMessage); } } } @@ -84,28 +92,43 @@ Future showShortcutInstallDialog( if (selectedTabId == null) return; final tabState = ref.read(tabStateProvider(selectedTabId)); - final name = tabState?.title ?? 'this site'; + final defaultName = tabState?.title.trim().isNotEmpty == true + ? 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( + final config = await showShortcutChoiceBottomSheet( context, - name: name, + defaultName: defaultName, url: url, showAppOption: showAppOption, ); - if (choice == null) return; + if (config == null) return; + + final name = config.name; + final overrideName = name == defaultName ? null : name; try { final bool success; - switch (choice) { + switch (config.type) { case ShortcutInstallType.shortcut: - success = await ref.read(installBasicShortcutProvider().future); + success = await ref.read( + installBasicShortcutProvider( + overrideName: overrideName, + contextId: config.contextId, + ).future, + ); case ShortcutInstallType.app: - success = await ref.read(installCurrentWebAppProvider.future); + success = await ref.read( + installCurrentWebAppProvider( + overrideName: overrideName, + contextId: config.contextId, + ).future, + ); } if (context.mounted) { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index 7a04ad21..070d7430 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -51,21 +51,26 @@ class SelectedContainer extends _$SelectedContainer { return null; } - Future setContainerId(String id) async { + Future setContainerId( + String id, { + bool Function()? shouldApply, + }) async { final container = await ref .read(containerRepositoryProvider.notifier) .getContainerData(id); - if (ref.mounted && container != null) { + bool canApply() => shouldApply?.call() ?? true; + + if (ref.mounted && container != null && canApply()) { if (container.metadata.useProxy) { final proxyPluginHealthy = await GeckoContainerProxyService() .healthcheck(); - if (proxyPluginHealthy) { + if (ref.mounted && proxyPluginHealthy && canApply()) { state = id; return SetContainerResult.successHasProxy; } - } else { + } else if (canApply()) { state = id; return SetContainerResult.success; } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index 362b9d7c..a92614f4 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -41,7 +41,7 @@ final class SelectedContainerProvider } } -String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417'; +String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c'; abstract class _$SelectedContainer extends $Notifier { String? build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart index bef739cc..801ce118 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart @@ -36,8 +36,14 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart /// Long pressing opens the edit screen for the selected container. class CompactContainerSelector extends ConsumerWidget { final ContainerData? selectedContainer; + final Future Function(ContainerSelectionResult selection)? + onSelectionChanged; - const CompactContainerSelector({super.key, this.selectedContainer}); + const CompactContainerSelector({ + super.key, + this.selectedContainer, + this.onSelectionChanged, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -73,6 +79,14 @@ class CompactContainerSelector extends ConsumerWidget { onPressed: () async { final selection = await const ContainerSelectionRoute() .push(context); + if (selection == null) { + return; + } + + if (onSelectionChanged != null) { + await onSelectionChanged!(selection); + return; + } switch (selection) { case ContainerSelectionSelected(:final containerId): @@ -81,8 +95,6 @@ class CompactContainerSelector extends ConsumerWidget { .setContainerId(containerId); case ContainerSelectionUnassigned(): ref.read(selectedContainerProvider.notifier).clearContainer(); - case null: - break; } }, ), diff --git a/apps/weblibre/lib/features/share_intent/domain/entities/intent_container_mode.dart b/apps/weblibre/lib/features/share_intent/domain/entities/intent_container_mode.dart new file mode 100644 index 00000000..822359be --- /dev/null +++ b/apps/weblibre/lib/features/share_intent/domain/entities/intent_container_mode.dart @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +enum IntentContainerMode { + useSelected('use-selected'), + unassigned('unassigned'), + specific('specific'); + + const IntentContainerMode(this.wireValue); + + final String wireValue; + + String? get queryValueOrNull => + this == IntentContainerMode.useSelected ? null : wireValue; + + static IntentContainerMode fromWireValue( + String? wireValue, { + String? contextId, + }) { + for (final mode in values) { + if (mode.wireValue == wireValue) { + return mode; + } + } + + if (contextId != null) { + return IntentContainerMode.specific; + } + + return IntentContainerMode.useSelected; + } +} diff --git a/apps/weblibre/lib/features/share_intent/domain/entities/shared_content.dart b/apps/weblibre/lib/features/share_intent/domain/entities/shared_content.dart index 3c5465dc..b87bbd4c 100644 --- a/apps/weblibre/lib/features/share_intent/domain/entities/shared_content.dart +++ b/apps/weblibre/lib/features/share_intent/domain/entities/shared_content.dart @@ -18,29 +18,42 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; import 'package:weblibre/utils/input_classification.dart'; sealed class SharedContent with FastEquatable { final String? contextId; + final IntentContainerMode containerMode; - SharedContent({this.contextId}); + SharedContent({ + this.contextId, + this.containerMode = IntentContainerMode.useSelected, + }); - factory SharedContent.parse(String content, {String? contextId}) { + factory SharedContent.parse( + String content, { + String? contextId, + IntentContainerMode containerMode = IntentContainerMode.useSelected, + }) { if (parseSharedIntentUrl(content) case final Uri uri) { - return SharedUrl(uri, contextId: contextId); + return SharedUrl(uri, contextId: contextId, containerMode: containerMode); } else { - return SharedText(content, contextId: contextId); + return SharedText( + content, + contextId: contextId, + containerMode: containerMode, + ); } } @override - List get hashParameters => [contextId]; + List get hashParameters => [contextId, containerMode]; } final class SharedUrl extends SharedContent { final Uri url; - SharedUrl(this.url, {super.contextId}); + SharedUrl(this.url, {super.contextId, super.containerMode}); @override String toString() => url.toString(); @@ -52,7 +65,7 @@ final class SharedUrl extends SharedContent { final class SharedText extends SharedContent { final String text; - SharedText(this.text, {super.contextId}); + SharedText(this.text, {super.contextId, super.containerMode}); @override String toString() => text; diff --git a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart index 7c3b2f07..1c5352e3 100644 --- a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart +++ b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart @@ -28,93 +28,109 @@ import 'package:uri_to_file/uri_to_file.dart' as uri_to_file; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/data/models/received_intent_parameter.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart'; +import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart'; part 'sharing_intent.g.dart'; StreamTransformer -_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) => - StreamTransformer.fromHandlers( - handleData: (intent, sink) async { - // PWA shortcut intents carry our own signed context id — always allow. - final pwaContextId = - intent.action == 'android.intent.action.VIEW' - ? intent.extra['pwa_context_id'] as String? - : null; +_buildSharingIntentTransformer( + IntentGatekeeper gatekeeper, +) => StreamTransformer.fromHandlers( + handleData: (intent, sink) async { + final shortcutContextId = intent.action == 'android.intent.action.VIEW' + ? intent.extra['pwa_context_id'] as String? + : null; + final containerMode = intent.action == 'android.intent.action.VIEW' + ? IntentContainerMode.fromWireValue( + intent.extra['shortcut_container_mode'] as String?, + contextId: shortcutContextId, + ) + : IntentContainerMode.useSelected; - if (pwaContextId == null) { - final allowed = await gatekeeper.shouldAllow( - fromPackageName: intent.fromPackageName, - url: intent.data, - ); - if (!allowed) { - logger.i( - 'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}', - ); - return; - } - } + final allowed = await gatekeeper.shouldAllow( + fromPackageName: intent.fromPackageName, + url: intent.data, + ); + if (!allowed) { + logger.i( + 'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}', + ); + return; + } - final data = switch (intent.action) { - 'android.intent.action.PROCESS_TEXT' => - intent.extra['android.intent.extra.PROCESS_TEXT'] as String?, - 'android.intent.action.WEB_SEARCH' => - intent.extra['query'] as String?, - 'android.intent.action.VIEW' => intent.data, - 'android.intent.action.SEND' => - intent.extra['android.intent.extra.STREAM'] as String? ?? - intent.extra['android.intent.extra.TEXT'] as String?, - _ => null, - }; + final data = switch (intent.action) { + 'android.intent.action.PROCESS_TEXT' => + intent.extra['android.intent.extra.PROCESS_TEXT'] as String?, + 'android.intent.action.WEB_SEARCH' => intent.extra['query'] as String?, + 'android.intent.action.VIEW' => intent.data, + 'android.intent.action.SEND' => + intent.extra['android.intent.extra.STREAM'] as String? ?? + intent.extra['android.intent.extra.TEXT'] as String?, + _ => null, + }; - // Extract container context from shortcut intents - final contextId = pwaContextId; + // Extract container context from shortcut intents. + final contextId = shortcutContextId; - if (data != null) { - if (uri_to_file.isUriSupported(data)) { - var path = data; - if (p.extension(data).whenNotEmpty == null) { - if (intent.mimeType.whenNotEmpty != null) { - final ext = mime.extensionFromMime(intent.mimeType!); - if (ext != null) { - path = p.setExtension(path, '.$ext'); - } else { - logger.w( - 'Could not determine file extension for: ${intent.mimeType}', - ); - } - } else { - logger.w( - 'Received intent without extension and mime type $path', - ); - } - } - - try { - final file = await uri_to_file.toFile(path); - final mimeType = mime.lookupMimeType(file.path); - switch (mimeType) { - case 'application/pdf': - 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, contextId: contextId), + if (data != null) { + if (uri_to_file.isUriSupported(data)) { + var path = data; + if (p.extension(data).whenNotEmpty == null) { + if (intent.mimeType.whenNotEmpty != null) { + final ext = mime.extensionFromMime(intent.mimeType!); + if (ext != null) { + path = p.setExtension(path, '.$ext'); + } else { + logger.w( + 'Could not determine file extension for: ${intent.mimeType}', ); } } else { - sink.add( - ReceivedIntentParameter(data, null, contextId: contextId), - ); + logger.w('Received intent without extension and mime type $path'); } } - }, - ); + + try { + final file = await uri_to_file.toFile(path); + final mimeType = mime.lookupMimeType(file.path); + switch (mimeType) { + case 'application/pdf': + sink.add( + ReceivedIntentParameter( + path, + null, + contextId: contextId, + containerMode: containerMode, + ), + ); + 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, + contextId: contextId, + containerMode: containerMode, + ), + ); + } + } else { + sink.add( + ReceivedIntentParameter( + data, + null, + contextId: contextId, + containerMode: containerMode, + ), + ); + } + } + }, +); @Riverpod(keepAlive: true) Raw> sharingIntentStream(Ref ref) { 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 05f21b1d..5098ac5f 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 @@ -16,11 +16,16 @@ object PwaConstants { 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" + const val EXTRA_SHORTCUT_CONTAINER_MODE = "shortcut_container_mode" // Shortcut type values const val SHORTCUT_TYPE_BASIC = "basic" const val SHORTCUT_TYPE_PWA = "pwa" + // Shortcut container mode values + const val SHORTCUT_CONTAINER_MODE_SPECIFIC = "specific" + const val SHORTCUT_CONTAINER_MODE_UNASSIGNED = "unassigned" + // Profile and file paths const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile" const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping" 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 506c8e2b..509d463d 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 @@ -222,14 +222,22 @@ class IntentReceiverActivity : Activity() { PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE, ) - val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}" - if (prefs.getString(tokenKey, null) == token) { + // Tokens are keyed by (url, profile, contextId) since each install + // variant gets its own token. Fall back to the legacy (url, profile) + // key for shortcuts pinned before context-scoping was introduced. + val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID).orEmpty() + val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}::${contextId}" + val legacyTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}" + if (prefs.getString(tokenKey, null) == token || + prefs.getString(legacyTokenKey, null) == token) { return true } if (!installStartUrl.isNullOrEmpty() && installStartUrl != intentUrl) { - val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}" - if (prefs.getString(installTokenKey, null) == token) { + val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}::${contextId}" + val legacyInstallTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}" + if (prefs.getString(installTokenKey, null) == token || + prefs.getString(legacyInstallTokenKey, null) == token) { return true } } 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 e019efd6..099d013a 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 @@ -52,6 +52,14 @@ class GeckoPwaApiImpl( private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) } + private enum class ShortcutKind( + val shortcutType: String, + val idPrefix: String, + ) { + PWA(PwaConstants.SHORTCUT_TYPE_PWA, "pwa"), + BASIC(PwaConstants.SHORTCUT_TYPE_BASIC, "shortcut"), + } + private val logger = Logger("GeckoPwaApiImpl") private val appPrefs by lazy { context.applicationContext.getSharedPreferences( @@ -68,6 +76,7 @@ class GeckoPwaApiImpl( tabId: String?, profileUuid: String, contextId: String?, + overrideAppName: String?, callback: (Result) -> Unit ) { logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId") @@ -86,7 +95,7 @@ class GeckoPwaApiImpl( return@launch } - val manifest = tab.content.webAppManifest ?: run { + val baseManifest = tab.content.webAppManifest ?: run { // Generate a synthetic manifest for sites without one val url = tab.content.url val title = tab.content.title.ifBlank { url } @@ -99,17 +108,26 @@ class GeckoPwaApiImpl( ) } + val manifest = overrideAppName?.takeIf { it.isNotBlank() }?.let { name -> + baseManifest.copy(name = name, shortName = name) + } ?: baseManifest + logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}") val success = createPwaShortcut( manifest = manifest, profileUuid = profileUuid, contextId = contextId, + tabFavicon = tab.content.icon, ) if (success) { - components.core.webAppManifestStorage.saveManifest(manifest) - storeProfileMapping(manifest.startUrl, profileUuid) + // Persist the unmodified manifest so a second install + // of the same URL with a different overrideAppName or + // contextId does not clobber the first install's + // standalone-window metadata. The user-chosen label + // lives on the shortcut itself. + components.core.webAppManifestStorage.saveManifest(baseManifest) logger.debug("Web app installation completed for tab ${tab.id}") } else { logger.warn("Failed to create PWA shortcut for tab ${tab.id}") @@ -130,6 +148,7 @@ class GeckoPwaApiImpl( manifest: WebAppManifest, profileUuid: String, contextId: String?, + tabFavicon: Bitmap?, ): Boolean = withContext(Dispatchers.Main) { try { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -148,18 +167,30 @@ class GeckoPwaApiImpl( return@withContext false } - val (iconBitmap, isMaskable) = loadPwaIcon(manifest) + // Prefer a manifest icon when available; for synthetic manifests + // (no icons declared) fall back to the tab favicon so we always + // ship a shortcut icon — some launchers crash when pinning a + // shortcut without one. + val iconBitmap = loadPwaIcon(manifest) + ?: loadTabFaviconBitmap(manifest.startUrl, tabFavicon) - val shortcutId = generateShortcutId(manifest.startUrl, profileUuid) + val appName = manifest.shortName ?: manifest.name ?: "Web App" + + val shortcutId = resolveShortcutId( + shortcutManager = shortcutManager, + url = manifest.startUrl, + profileUuid = profileUuid, + contextId = contextId, + shortcutKind = ShortcutKind.PWA, + ) val launchToken = resolveLaunchToken( shortcutManager = shortcutManager, shortcutId = shortcutId, startUrl = manifest.startUrl, profileUuid = profileUuid, + contextId = contextId, ) - val appName = manifest.shortName ?: manifest.name ?: "Web App" - val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply { action = Intent.ACTION_VIEW data = Uri.parse(manifest.startUrl) @@ -167,7 +198,11 @@ 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) + putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.PWA.shortcutType) + putExtra( + PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE, + resolveShortcutContainerMode(contextId), + ) } val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { @@ -176,19 +211,17 @@ class GeckoPwaApiImpl( setIntent(shortcutIntent) if (iconBitmap != null) { - // Only use adaptive bitmap for maskable icons (designed for adaptive shapes) - // Regular icons should use createWithBitmap to display as-is - if (isMaskable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - setIcon(Icon.createWithAdaptiveBitmap(iconBitmap)) - } else { - setIcon(Icon.createWithBitmap(iconBitmap)) - } + // Always use createWithBitmap (never createWithAdaptiveBitmap): + // Launcher3's pin-preview routes adaptive icons through + // AdaptiveIconDrawable + BitmapShader and promotes intermediates + // to HARDWARE, crashing the software preview canvas. + setIcon(Icon.createWithBitmap(iconBitmap)) } }.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. + // Update an existing install of the same kind in place. Some + // launchers reuse cached shortcut metadata unless we explicitly + // refresh the pinned record first. updateExistingShortcut(shortcutManager, shortcut) val success = shortcutManager.requestPinShortcut(shortcut, null) @@ -218,53 +251,129 @@ class GeckoPwaApiImpl( } /** - * Generates a collision-resistant shortcut ID from URL + profile using SHA-256. + * Resolves the shortcut ID for the current install kind. + * + * New installs use a kind-specific ID so a standalone PWA and a regular + * shortcut for the same site do not overwrite each other. For minimal + * migration handling, we still reuse the legacy shared ID if a pinned + * shortcut with that ID already exists for the same kind. */ - private fun generateShortcutId(url: String, profileUuid: String): String { + private fun resolveShortcutId( + shortcutManager: ShortcutManager, + url: String, + profileUuid: String, + contextId: String? = null, + shortcutKind: ShortcutKind, + ): String { + val typedShortcutId = generateShortcutId( + url = url, + profileUuid = profileUuid, + contextId = contextId, + shortcutKind = shortcutKind, + ) + if (shortcutManager.pinnedShortcuts.any { it.id == typedShortcutId }) { + return typedShortcutId + } + + val legacyShortcutId = generateLegacyShortcutId( + url = url, + profileUuid = profileUuid, + contextId = contextId, + ) + val matchingLegacyShortcut = shortcutManager.pinnedShortcuts.firstOrNull { shortcut -> + if (shortcut.id != legacyShortcutId) { + return@firstOrNull false + } + + val shortcutIntent = shortcut.intent ?: return@firstOrNull false + shortcutIntent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == profileUuid && + shortcutIntent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) == shortcutKind.shortcutType + } + return matchingLegacyShortcut?.id ?: typedShortcutId + } + + /** + * Generates a collision-resistant shortcut ID from (type, url, profile, + * contextId) using SHA-256. The display label is deliberately excluded + * because it is presentation, not install identity. + */ + private fun generateShortcutId( + url: String, + profileUuid: String, + contextId: String? = null, + shortcutKind: ShortcutKind, + ): String { val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest("$url::$profileUuid".toByteArray()) + val key = if (contextId.isNullOrEmpty()) { + "${shortcutKind.shortcutType}::$url::$profileUuid" + } else { + "${shortcutKind.shortcutType}::$url::$profileUuid::$contextId" + } + val hash = digest.digest(key.toByteArray()) + val hex = hash.take(16).joinToString("") { "%02x".format(it) } + return "${shortcutKind.idPrefix}_$hex" + } + + /** + * Historical shared shortcut ID used before install kind became part of + * the identity. Both PWAs and basic shortcuts previously reused this ID. + */ + private fun generateLegacyShortcutId( + url: String, + profileUuid: String, + contextId: String? = null, + ): String { + val digest = MessageDigest.getInstance("SHA-256") + val key = if (contextId.isNullOrEmpty()) { + "$url::$profileUuid" + } else { + "$url::$profileUuid::$contextId" + } + val hash = digest.digest(key.toByteArray()) val hex = hash.take(16).joinToString("") { "%02x".format(it) } return "pwa_$hex" } + private fun resolveShortcutContainerMode(contextId: String?): String { + return if (contextId.isNullOrEmpty()) { + PwaConstants.SHORTCUT_CONTAINER_MODE_UNASSIGNED + } else { + PwaConstants.SHORTCUT_CONTAINER_MODE_SPECIFIC + } + } + /** - * Loads the PWA icon from the manifest using BrowserIcons. - * Returns a pair of (bitmap, isMaskable) to determine proper icon format. + * Loads the PWA icon from the manifest using BrowserIcons. Requested at + * plain LAUNCHER size since the shortcut is set as a non-adaptive bitmap. */ - private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair = withContext(Dispatchers.IO) { + private suspend fun loadPwaIcon(manifest: WebAppManifest): Bitmap? = withContext(Dispatchers.IO) { try { val iconResource = manifest.icons .filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) || it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) } .maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) } ?: manifest.icons.firstOrNull() + ?: return@withContext null - if (iconResource != null) { - val isMaskable = iconResource.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) - val iconRequest = IconRequest( - url = manifest.startUrl, - size = IconRequest.Size.LAUNCHER_ADAPTIVE, - resources = listOf( - IconRequest.Resource( - url = iconResource.src, - type = IconRequest.Resource.Type.MANIFEST_ICON, - sizes = iconResource.sizes?.map { size -> - mozilla.components.concept.engine.manifest.Size(size.width, size.height) - } ?: emptyList(), - mimeType = iconResource.type, - maskable = isMaskable - ) + val iconRequest = IconRequest( + url = manifest.startUrl, + size = IconRequest.Size.LAUNCHER, + resources = listOf( + IconRequest.Resource( + url = iconResource.src, + type = IconRequest.Resource.Type.MANIFEST_ICON, + sizes = iconResource.sizes?.map { size -> + mozilla.components.concept.engine.manifest.Size(size.width, size.height) + } ?: emptyList(), + mimeType = iconResource.type, ) ) + ) - val iconResult = components.core.icons.loadIcon(iconRequest).await() - Pair(iconResult?.bitmap, isMaskable) - } else { - Pair(null, false) - } + components.core.icons.loadIcon(iconRequest).await()?.bitmap } catch (e: Exception) { logger.error("Failed to load PWA icon", e) - Pair(null, false) + null } } @@ -335,16 +444,23 @@ class GeckoPwaApiImpl( return@withContext false } - val shortcutId = generateShortcutId(url, profileUuid) + val shortLabel = title.ifBlank { url } + + val shortcutId = resolveShortcutId( + shortcutManager = shortcutManager, + url = url, + profileUuid = profileUuid, + contextId = contextId, + shortcutKind = ShortcutKind.BASIC, + ) val launchToken = resolveLaunchToken( shortcutManager = shortcutManager, shortcutId = shortcutId, startUrl = url, profileUuid = profileUuid, + contextId = contextId, ) - val shortLabel = title.ifBlank { url } - val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply { action = Intent.ACTION_VIEW data = Uri.parse(url) @@ -352,7 +468,11 @@ class GeckoPwaApiImpl( 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) + putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.BASIC.shortcutType) + putExtra( + PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE, + resolveShortcutContainerMode(contextId), + ) } val icon = loadTabIcon(url, tabIcon) @@ -364,7 +484,7 @@ class GeckoPwaApiImpl( icon?.let { setIcon(it) } }.build() - // Update existing shortcut intent if one exists with the same ID + // Update an existing install of the same kind in place. updateExistingShortcut(shortcutManager, shortcut) val success = shortcutManager.requestPinShortcut(shortcut, null) @@ -377,31 +497,37 @@ class GeckoPwaApiImpl( } /** - * Loads an icon for the shortcut from the tab's favicon or BrowserIcons. + * Loads a favicon-style bitmap for [url], preferring the in-memory tab icon + * and falling back to BrowserIcons at LAUNCHER size. Returns a live bitmap + * (caller is responsible for converting to software before use). */ - private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) { + private suspend fun loadTabFaviconBitmap( + url: String, + tabIcon: Bitmap?, + ): Bitmap? = withContext(Dispatchers.IO) { try { - // Try using the tab's existing favicon first - val bitmap = tabIcon?.takeUnless { it.isRecycled } + 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) + logger.error("Failed to load tab favicon bitmap", e) null } } + /** + * Loads an icon for the shortcut from the tab's favicon or BrowserIcons. + */ + private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? { + val bitmap = loadTabFaviconBitmap(url, tabIcon) + return bitmap?.takeUnless { it.isRecycled }?.let(Icon::createWithBitmap) + } + /** * Extracts the scope from a URL (origin + path up to last segment). */ @@ -425,14 +551,51 @@ class GeckoPwaApiImpl( coroutineScope.launch { try { val storage = components.core.webAppManifestStorage - val manifests = storage.loadShareableManifests(System.currentTimeMillis()) val currentProfileUuid = getCurrentProfileUuid() - val pwaManifests = manifests.filter { manifest -> - val mappedProfile = getProfileMapping(manifest.startUrl) - currentProfileUuid == null || mappedProfile == null || mappedProfile == currentProfileUuid - }.map { manifest -> - manifest.toPwaManifest() + + // Pinned shortcuts are the source of truth for installs: each + // pinned shortcut carries its own label, profile, and + // contextId in its intent extras. Two installs of the same + // URL with different contextIds or labels are two distinct + // shortcuts here, even though Mozilla's manifest storage + // keys the underlying manifest by URL only. We join the + // shared manifest with per-install fields from each shortcut. + val pwaShortcuts = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.getSystemService()?.pinnedShortcuts + ?.filter { shortcut -> + val intent = shortcut.intent ?: return@filter false + intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) == + PwaConstants.SHORTCUT_TYPE_PWA + } + ?: emptyList() + } else { + emptyList() } + + val pwaManifests = pwaShortcuts.mapNotNull { shortcut -> + val intent = shortcut.intent ?: return@mapNotNull null + val shortcutProfile = + intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) + if (currentProfileUuid != null && + shortcutProfile != null && + shortcutProfile != currentProfileUuid + ) { + return@mapNotNull null + } + + val installStartUrl = + intent.getStringExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL) + ?: intent.dataString + ?: return@mapNotNull null + val manifest = storage.loadManifest(installStartUrl) + ?: return@mapNotNull null + + manifest.toPwaManifest( + contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID), + installLabel = shortcut.shortLabel?.toString(), + ) + } + logger.debug("Found ${pwaManifests.size} installed web apps") callback(Result.success(pwaManifests)) } catch (e: Exception) { @@ -442,19 +605,14 @@ class GeckoPwaApiImpl( } } - private fun storeProfileMapping(startUrl: String, profileUuid: String) { - appPrefs.edit() - .putString(startUrl, profileUuid) - .apply() - } - private fun resolveLaunchToken( shortcutManager: ShortcutManager, shortcutId: String, startUrl: String, profileUuid: String, + contextId: String?, ): String { - val storedToken = getStoredLaunchToken(startUrl, profileUuid) + val storedToken = getStoredLaunchToken(startUrl, profileUuid, contextId) val existingShortcutToken = shortcutManager.pinnedShortcuts .firstOrNull { shortcut -> shortcut.id == shortcutId } ?.intent @@ -464,7 +622,7 @@ class GeckoPwaApiImpl( ?.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN) if (!existingShortcutToken.isNullOrEmpty()) { - val committed = storeLaunchToken(startUrl, profileUuid, existingShortcutToken) + val committed = storeLaunchToken(startUrl, profileUuid, contextId, existingShortcutToken) if (!committed) { logger.warn("Failed to persist pinned shortcut PWA token for $startUrl") } @@ -472,7 +630,7 @@ class GeckoPwaApiImpl( } if (!storedToken.isNullOrEmpty()) { - val committed = storeLaunchToken(startUrl, profileUuid, storedToken) + val committed = storeLaunchToken(startUrl, profileUuid, contextId, storedToken) if (!committed) { logger.warn("Failed to refresh stored PWA launch token index for $startUrl") } @@ -480,27 +638,40 @@ class GeckoPwaApiImpl( } val generatedToken = UUID.randomUUID().toString() - val committed = storeLaunchToken(startUrl, profileUuid, generatedToken) + val committed = storeLaunchToken(startUrl, profileUuid, contextId, generatedToken) if (!committed) { logger.warn("Failed to persist PWA launch token for $startUrl") } return generatedToken } - private fun getStoredLaunchToken(startUrl: String, profileUuid: String): String? { - val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}" - return appPrefs.getString(tokenKey, null) + private fun tokenKey(startUrl: String, profileUuid: String, contextId: String?): String { + // Keyed by (startUrl, profileUuid, contextId) so multiple installs of + // the same URL with different storage contexts each keep their own + // token and don't share or overwrite each other. + return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}::${contextId.orEmpty()}" } - private fun storeLaunchToken(startUrl: String, profileUuid: String, token: String): Boolean { - val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}" - return appPrefs.edit() - .putString(tokenKey, token) - .commit() + private fun legacyTokenKey(startUrl: String, profileUuid: String): String { + return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}" } - private fun getProfileMapping(startUrl: String): String? { - return appPrefs.getString(startUrl, null) + private fun getStoredLaunchToken(startUrl: String, profileUuid: String, contextId: String?): String? { + return appPrefs.getString(tokenKey(startUrl, profileUuid, contextId), null) + ?: if (contextId.isNullOrEmpty()) { + appPrefs.getString(legacyTokenKey(startUrl, profileUuid), null) + } else { + null + } + } + + private fun storeLaunchToken(startUrl: String, profileUuid: String, contextId: String?, token: String): Boolean { + return appPrefs.edit().apply { + putString(tokenKey(startUrl, profileUuid, contextId), token) + if (contextId.isNullOrEmpty()) { + putString(legacyTokenKey(startUrl, profileUuid), token) + } + }.commit() } private fun getCurrentProfileUuid(): String? { @@ -517,10 +688,16 @@ class GeckoPwaApiImpl( } } - private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest { + private fun WebAppManifest.toPwaManifest( + currentUrl: String = startUrl, + contextId: String? = null, + installLabel: String? = null, + ): PwaManifest { return PwaManifest( startUrl = startUrl, currentUrl = currentUrl, + contextId = contextId, + installLabel = installLabel, name = name, shortName = shortName, display = display?.name?.lowercase()?.replace("_", "-"), 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 9152b73a..3019cb8e 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 @@ -5124,7 +5124,22 @@ data class PwaManifest ( * The URL of the page when the manifest was detected. * Used for HTTPS/installability checks. */ - val currentUrl: String + val currentUrl: String, + /** + * Storage context (container contextualIdentity or isolated `iso1_…` id) + * that this install was pinned with. Only populated by + * `getInstalledWebApps` — read from the pinned shortcut's intent extras, + * not from the manifest itself, because the same URL can have multiple + * installs that differ only in contextId. + */ + val contextId: String? = null, + /** + * User-chosen launcher label for this specific install (from the pinned + * shortcut's shortLabel). May differ from `name`/`shortName` because the + * underlying manifest is shared across install variants of the same URL. + * Only populated by `getInstalledWebApps`. + */ + val installLabel: String? = null ) { companion object { @@ -5145,7 +5160,9 @@ data class PwaManifest ( val preferRelatedApplications = pigeonVar_list[13] as Boolean val shareTarget = pigeonVar_list[14] as ShareTarget? val currentUrl = pigeonVar_list[15] as String - return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl) + val contextId = pigeonVar_list[16] as String? + val installLabel = pigeonVar_list[17] as String? + return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl, contextId, installLabel) } } fun toList(): List { @@ -5166,6 +5183,8 @@ data class PwaManifest ( preferRelatedApplications, shareTarget, currentUrl, + contextId, + installLabel, ) } override fun equals(other: Any?): Boolean { @@ -5176,7 +5195,7 @@ data class PwaManifest ( return true } val other = other as PwaManifest - return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl) + return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.installLabel, other.installLabel) } override fun hashCode(): Int { @@ -5197,6 +5216,8 @@ data class PwaManifest ( result = 31 * result + GeckoPigeonUtils.deepHash(this.preferRelatedApplications) result = 31 * result + GeckoPigeonUtils.deepHash(this.shareTarget) result = 31 * result + GeckoPigeonUtils.deepHash(this.currentUrl) + result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.installLabel) return result } } @@ -10635,9 +10656,10 @@ interface GeckoPwaApi { * The [tabId] identifies which tab to install from. 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, null for default container). + * The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest. * Returns true if installation was successful. */ - fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result) -> Unit) + fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, overrideAppName: String?, callback: (Result) -> Unit) /** Returns a list of all installed PWA manifests. */ fun getInstalledWebApps(callback: (Result>) -> Unit) /** @@ -10672,7 +10694,8 @@ interface GeckoPwaApi { val tabIdArg = args[0] as String? val profileUuidArg = args[1] as String val contextIdArg = args[2] as String? - api.installWebApp(tabIdArg, profileUuidArg, contextIdArg) { result: Result -> + val overrideAppNameArg = args[3] as String? + api.installWebApp(tabIdArg, profileUuidArg, contextIdArg, overrideAppNameArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(GeckoPigeonUtils.wrapError(error)) 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 008237d2..d2514c01 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -5474,6 +5474,8 @@ class PwaManifest { required this.preferRelatedApplications, this.shareTarget, required this.currentUrl, + this.contextId, + this.installLabel, }); String startUrl; @@ -5510,6 +5512,19 @@ class PwaManifest { /// Used for HTTPS/installability checks. String currentUrl; + /// Storage context (container contextualIdentity or isolated `iso1_…` id) + /// that this install was pinned with. Only populated by + /// `getInstalledWebApps` — read from the pinned shortcut's intent extras, + /// not from the manifest itself, because the same URL can have multiple + /// installs that differ only in contextId. + String? contextId; + + /// User-chosen launcher label for this specific install (from the pinned + /// shortcut's shortLabel). May differ from `name`/`shortName` because the + /// underlying manifest is shared across install variants of the same URL. + /// Only populated by `getInstalledWebApps`. + String? installLabel; + List _toList() { return [ startUrl, @@ -5528,6 +5543,8 @@ class PwaManifest { preferRelatedApplications, shareTarget, currentUrl, + contextId, + installLabel, ]; } @@ -5553,6 +5570,8 @@ class PwaManifest { preferRelatedApplications: result[13]! as bool, shareTarget: result[14] as ShareTarget?, currentUrl: result[15]! as String, + contextId: result[16] as String?, + installLabel: result[17] as String?, ); } @@ -5565,7 +5584,7 @@ class PwaManifest { if (identical(this, other)) { return true; } - return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl); + return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl) && _deepEquals(contextId, other.contextId) && _deepEquals(installLabel, other.installLabel); } @override @@ -10499,15 +10518,16 @@ class GeckoPwaApi { /// The [tabId] identifies which tab to install from. 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, null for default container). + /// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest. /// Returns true if installation was successful. - Future installWebApp(String? tabId, String profileUuid, String? contextId) async { + Future installWebApp(String? tabId, String profileUuid, String? contextId, String? overrideAppName) async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([tabId, profileUuid, contextId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([tabId, profileUuid, contextId, overrideAppName]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 0c57324b..8bab8466 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -2673,6 +2673,19 @@ class PwaManifest { /// Used for HTTPS/installability checks. final String currentUrl; + /// Storage context (container contextualIdentity or isolated `iso1_…` id) + /// that this install was pinned with. Only populated by + /// `getInstalledWebApps` — read from the pinned shortcut's intent extras, + /// not from the manifest itself, because the same URL can have multiple + /// installs that differ only in contextId. + final String? contextId; + + /// User-chosen launcher label for this specific install (from the pinned + /// shortcut's shortLabel). May differ from `name`/`shortName` because the + /// underlying manifest is shared across install variants of the same URL. + /// Only populated by `getInstalledWebApps`. + final String? installLabel; + const PwaManifest({ required this.startUrl, required this.currentUrl, @@ -2690,6 +2703,8 @@ class PwaManifest { this.relatedApplications = const [], this.preferRelatedApplications = false, this.shareTarget, + this.contextId, + this.installLabel, }); } @@ -2708,9 +2723,15 @@ abstract class GeckoPwaApi { /// The [tabId] identifies which tab to install from. 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, null for default container). + /// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest. /// Returns true if installation was successful. @async - bool installWebApp(String? tabId, String profileUuid, String? contextId); + bool installWebApp( + String? tabId, + String profileUuid, + String? contextId, + String? overrideAppName, + ); /// Returns a list of all installed PWA manifests. @async