From 188dfdd003e268c54af85023d086fcaa2e34b027 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 10 Feb 2026 16:20:54 +0100 Subject: [PATCH] initial pwa + custom tabs --- app/android/app/src/main/AndroidManifest.xml | 51 +- .../app/src/main/res/values/styles.xml | 5 + app/lib/core/filesystem.dart | 4 + .../widgets/browser_modules/browser_view.dart | 15 + .../presentation/widgets/tab_menu.dart | 23 + .../features/pwa/domain/providers.dart | 123 ++++ .../features/pwa/domain/providers.g.dart | 298 ++++++++++ .../pwa/domain/pwa_installability.dart | 132 +++++ .../widgets/pwa_install_button.dart | 77 +++ .../user/domain/repositories/profile.dart | 6 + .../user/domain/repositories/profile.g.dart | 2 +- .../user/domain/services/user_backup.dart | 51 +- .../user/domain/services/user_backup.g.dart | 2 +- app/lib/utils/filesystem.dart | 24 + .../android/build.gradle | 4 + .../BaseBrowserFragment.kt | 38 +- .../BrowserFragment.kt | 11 + .../flutter_mozilla_components/Components.kt | 8 +- .../ExternalAppBrowserFragment.kt | 371 ++++++++++++ .../ProfileContext.kt | 7 + .../PwaConstants.kt | 21 + .../activities/ExternalAppBrowserActivity.kt | 252 +++++++++ .../activities/IntentReceiverActivity.kt | 385 +++++++++++++ .../api/GeckoBrowserApiImpl.kt | 4 + .../api/GeckoPwaApiImpl.kt | 324 +++++++++++ .../api/GeckoSessionApiImpl.kt | 2 +- .../api/GeckoViewportApiImpl.kt | 63 +-- .../components/Core.kt | 10 + .../components/Events.kt | 112 +++- .../components/UseCases.kt | 7 + .../pigeons/Gecko.g.kt | 530 ++++++++++++++++-- .../ui/LoadingScreenManager.kt | 297 ++++++++++ .../widget/CustomTabToolbar.kt | 190 +++++++ .../widget/CustomTabToolbarFeature.kt | 51 ++ .../main/res/drawable/custom_tab_menu_bg.xml | 10 + .../src/main/res/drawable/pulse_ripple.xml | 10 + .../layout/activity_external_app_browser.xml | 10 + .../res/layout/custom_tab_loading_screen.xml | 54 ++ .../src/main/res/layout/custom_tab_menu.xml | 157 ++++++ .../main/res/layout/custom_tab_toolbar.xml | 94 ++++ .../src/main/res/layout/fragment_browser.xml | 12 +- .../main/res/layout/pwa_loading_screen.xml | 88 +++ .../android/src/main/res/values/strings.xml | 15 + .../android/src/main/res/values/styles.xml | 6 + .../lib/flutter_mozilla_components.dart | 3 + .../lib/src/domain/services/gecko_event.dart | 8 +- .../pigeons/gecko.dart | 137 ++++- 47 files changed, 3974 insertions(+), 130 deletions(-) create mode 100644 app/lib/features/geckoview/features/pwa/domain/providers.dart create mode 100644 app/lib/features/geckoview/features/pwa/domain/providers.g.dart create mode 100644 app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart create mode 100644 app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ui/LoadingScreenManager.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbar.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbarFeature.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/res/drawable/custom_tab_menu_bg.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/drawable/pulse_ripple.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/layout/activity_external_app_browser.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_loading_screen.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_menu.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_toolbar.xml create mode 100644 packages/flutter_mozilla_components/android/src/main/res/layout/pwa_loading_screen.xml diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index ee68f21f..b3413864 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -100,12 +100,35 @@ + + + + + + + + + + + + + + + + @@ -123,7 +146,7 @@ - + @@ -133,19 +156,29 @@ + - - - - - - - - + + + + + @style/PreferenceThemeOverlay + + diff --git a/app/lib/core/filesystem.dart b/app/lib/core/filesystem.dart index cc6ac308..ff21e920 100644 --- a/app/lib/core/filesystem.dart +++ b/app/lib/core/filesystem.dart @@ -70,6 +70,10 @@ class _Filesystem { return fs.clearMozillaProfileCache(profileId); } + List getMozillaProfileIds(UuidValue uuid) { + return fs.getMozillaProfileIds(getProfileDir(uuid)); + } + Future checkForDuplicateMozillaProfile(UuidValue profile) async { final duplicates = await fs .getProfilesWithDuplicateMozillaProfiles(profilesDir) diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index e7e37da5..3b605e38 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -48,6 +48,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/eng import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; +import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; @@ -503,6 +504,20 @@ class _BrowserViewState extends ConsumerState ); }, ); + + // Ensure PWA manifest state is collected and stays alive + ref.listenManual( + fireImmediately: true, + pwaManifestStateProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to pwaManifestStateProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); } @override diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 2a7d1d5c..1f98e5b0 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -37,6 +37,8 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; @@ -195,6 +197,27 @@ class TabMenu extends HookConsumerWidget { ).push(context); }, ), + Consumer( + builder: (context, ref, child) { + final isInstallable = ref.watch(isCurrentTabInstallableProvider); + + return Visibility( + visible: isInstallable, + child: MenuItemButton( + closeOnActivate: false, + leadingIcon: const Icon(Icons.add_to_home_screen), + child: const Text('Add to Home Screen'), + onPressed: () async { + await showPwaInstallDialog(context, ref); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + ), + ); + }, + ), if (enableCloneTab) SubmenuButton( menuChildren: [ diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.dart b/app/lib/features/geckoview/features/pwa/domain/providers.dart new file mode 100644 index 00000000..6bf4eb0a --- /dev/null +++ b/app/lib/features/geckoview/features/pwa/domain/providers.dart @@ -0,0 +1,123 @@ +/* + * 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 . + */ + +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/filesystem.dart' show filesystem; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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'; + +/// Stream of manifest update events from the event service. +@Riverpod(keepAlive: true) +Stream manifestUpdateEventsStream(Ref ref) { + final eventService = ref.watch(eventServiceProvider); + return eventService.manifestUpdateEvents; +} + +/// Manages PWA manifest state with a long-running subscription. +@Riverpod(keepAlive: true) +class PwaManifestState extends _$PwaManifestState { + PwaManifest? getManifest(String tabId) => state[tabId]; + + @override + Map build() { + // Listen to manifest update events using ref.listenManual + ref.listen( + manifestUpdateEventsStreamProvider, + (previous, next) { + if (next.hasValue) { + final event = next.value!; + // Update state with new manifest - this triggers rebuilds + state = {...state, event.tabId: event.manifest}; + } + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to manifest update events', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + return {}; + } +} + +/// PWA manifest for the currently selected tab. +@Riverpod() +PwaManifest? currentTabManifest(Ref ref) { + final selectedTabId = ref.watch(selectedTabProvider); + final manifestState = ref.watch(pwaManifestStateProvider); + + if (selectedTabId == null) return null; + + return manifestState[selectedTabId]; +} + +/// Boolean indicating if the current tab is installable as a PWA. +@Riverpod() +bool isCurrentTabInstallable(Ref ref) { + final manifest = ref.watch(currentTabManifestProvider); + + if (manifest == null) return false; + + return isManifestInstallable(manifest); +} + +/// 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 { + final selectedTabId = ref.read(selectedTabProvider); + + if (selectedTabId == null) { + throw StateError('No tab selected'); + } + + final profileUuid = filesystem.selectedProfile.uuid; + + final selectedContainerId = ref.read(selectedContainerProvider); + String? contextId; + + if (selectedContainerId != null) { + final containerRepository = ref.read(containerRepositoryProvider.notifier); + final containerData = await containerRepository.getContainerData( + selectedContainerId, + ); + contextId = containerData?.metadata.contextualIdentity; + } + + return GeckoPwaApi().installWebApp(selectedTabId, profileUuid, contextId); +} + +/// Returns all installed PWAs. +@Riverpod() +Future> installedWebApps(Ref ref) { + return GeckoPwaApi().getInstalledWebApps(); +} diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart new file mode 100644 index 00000000..7daf9555 --- /dev/null +++ b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Stream of manifest update events from the event service. + +@ProviderFor(manifestUpdateEventsStream) +final manifestUpdateEventsStreamProvider = + ManifestUpdateEventsStreamProvider._(); + +/// Stream of manifest update events from the event service. + +final class ManifestUpdateEventsStreamProvider + extends + $FunctionalProvider< + AsyncValue, + ManifestUpdateEvent, + Stream + > + with + $FutureModifier, + $StreamProvider { + /// Stream of manifest update events from the event service. + ManifestUpdateEventsStreamProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'manifestUpdateEventsStreamProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$manifestUpdateEventsStreamHash(); + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return manifestUpdateEventsStream(ref); + } +} + +String _$manifestUpdateEventsStreamHash() => + r'60035756c129c1801f2f1f4e7da6384abf78d8fa'; + +/// Manages PWA manifest state with a long-running subscription. + +@ProviderFor(PwaManifestState) +final pwaManifestStateProvider = PwaManifestStateProvider._(); + +/// Manages PWA manifest state with a long-running subscription. +final class PwaManifestStateProvider + extends $NotifierProvider> { + /// Manages PWA manifest state with a long-running subscription. + PwaManifestStateProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pwaManifestStateProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pwaManifestStateHash(); + + @$internal + @override + PwaManifestState create() => PwaManifestState(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1'; + +/// Manages PWA manifest state with a long-running subscription. + +abstract class _$PwaManifestState extends $Notifier> { + Map build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, Map>, + Map, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +/// PWA manifest for the currently selected tab. + +@ProviderFor(currentTabManifest) +final currentTabManifestProvider = CurrentTabManifestProvider._(); + +/// PWA manifest for the currently selected tab. + +final class CurrentTabManifestProvider + extends $FunctionalProvider + with $Provider { + /// PWA manifest for the currently selected tab. + CurrentTabManifestProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currentTabManifestProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currentTabManifestHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + PwaManifest? create(Ref ref) { + return currentTabManifest(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PwaManifest? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$currentTabManifestHash() => + r'6121287eaaa18d080154c8ffe6e2a4b8c2d144d3'; + +/// Boolean indicating if the current tab is installable as a PWA. + +@ProviderFor(isCurrentTabInstallable) +final isCurrentTabInstallableProvider = IsCurrentTabInstallableProvider._(); + +/// Boolean indicating if the current tab is installable as a PWA. + +final class IsCurrentTabInstallableProvider + extends $FunctionalProvider + with $Provider { + /// Boolean indicating if the current tab is installable as a PWA. + IsCurrentTabInstallableProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isCurrentTabInstallableProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isCurrentTabInstallableHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return isCurrentTabInstallable(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$isCurrentTabInstallableHash() => + r'293cdb6dcea24446343330ccdb30dc9f21f03618'; + +/// Installs the current tab as a PWA, embedding profile and container context +/// in the shortcut intent so the PWA reopens with the same isolation. + +@ProviderFor(installCurrentWebApp) +final installCurrentWebAppProvider = InstallCurrentWebAppProvider._(); + +/// 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 InstallCurrentWebAppProvider + extends $FunctionalProvider, bool, FutureOr> + 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, + ); + + @override + String debugGetCreateSourceHash() => _$installCurrentWebAppHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return installCurrentWebApp(ref); + } +} + +String _$installCurrentWebAppHash() => + r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f'; + +/// Returns all installed PWAs. + +@ProviderFor(installedWebApps) +final installedWebAppsProvider = InstalledWebAppsProvider._(); + +/// Returns all installed PWAs. + +final class InstalledWebAppsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Returns all installed PWAs. + InstalledWebAppsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'installedWebAppsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$installedWebAppsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return installedWebApps(ref); + } +} + +String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d'; diff --git a/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart b/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart new file mode 100644 index 00000000..dcdd9ec2 --- /dev/null +++ b/app/lib/features/geckoview/features/pwa/domain/pwa_installability.dart @@ -0,0 +1,132 @@ +/* + * 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 . + */ + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +/// Display modes that are valid for installable PWAs per W3C spec. +const _validDisplayModes = { + 'standalone', + 'fullscreen', + 'minimal-ui', + 'window-controls-overlay', +}; + +/// Determines if a PWA manifest meets installability criteria per W3C spec. +/// +/// A web app is installable if: +/// 1. Served over HTTPS (or localhost for development) +/// 2. Has a valid manifest with required fields: +/// - name (or short_name) +/// - start_url (must be same-origin and within scope) +/// - display mode (standalone, fullscreen, minimal-ui, or +/// window-controls-overlay) +/// 3. start_url is within the scope +/// 4. prefer_related_applications is not true +bool isManifestInstallable(PwaManifest manifest) { + // Check HTTPS requirement (relaxed for localhost) + final isSecure = + manifest.currentUrl.startsWith('https://') || + manifest.currentUrl.startsWith('http://localhost') || + manifest.currentUrl.startsWith('http://127.0.0.1'); + + if (!isSecure) { + return false; + } + + // prefer_related_applications must not be true + if (manifest.preferRelatedApplications) { + return false; + } + + // Check required manifest fields + final hasValidName = + manifest.name?.isNotEmpty == true || + manifest.shortName?.isNotEmpty == true; + + // W3C §1.10.6: start_url must be same-origin as the document URL + final hasValidStartUrl = + manifest.startUrl.isNotEmpty && + _isSameOrigin(manifest.startUrl, manifest.currentUrl); + + final hasValidDisplay = + manifest.display != null && + _validDisplayModes.contains(manifest.display!.toLowerCase()); + + // Check that start_url is within scope + final isInScope = _isStartUrlInScope( + manifest.startUrl, + manifest.scope, + ); + + return hasValidName && + hasValidStartUrl && + hasValidDisplay && + isInScope; +} + +/// Returns true if two URLs share the same origin (scheme + host + port). +bool _isSameOrigin(String url1, String url2) { + try { + final uri1 = Uri.parse(url1); + final uri2 = Uri.parse(url2); + return uri1.scheme == uri2.scheme && + uri1.host == uri2.host && + uri1.port == uri2.port; + } catch (e) { + return false; + } +} + +/// Checks if startUrl is within scope using URL path containment. +/// +/// Per W3C spec: when scope is absent, the default scope is the start_url +/// with its last path segment, query, and fragment removed. +/// Scope matching uses path-prefix comparison on `/` boundaries. +bool _isStartUrlInScope(String startUrl, String? scope) { + try { + final startUri = Uri.parse(startUrl); + + if (scope == null || scope.isEmpty) { + // Per W3C: default scope = start_url with filename/query/fragment removed + // The start_url is trivially within its own default scope. + return true; + } + + final scopeUri = Uri.parse(scope); + + // Must be same origin + if (startUri.scheme != scopeUri.scheme || + startUri.host != scopeUri.host || + startUri.port != scopeUri.port) { + return false; + } + + // Path containment: scope path must be a prefix of start_url path + // on a `/` boundary to avoid "/app" matching "/application" + final scopePath = + scopeUri.path.endsWith('/') ? scopeUri.path : '${scopeUri.path}/'; + final startPath = + startUri.path.endsWith('/') ? startUri.path : '${startUri.path}/'; + + return startPath.startsWith(scopePath) || startUri.path == scopeUri.path; + } catch (e) { + return false; + } +} diff --git a/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart new file mode 100644 index 00000000..e5bb33f6 --- /dev/null +++ b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart @@ -0,0 +1,77 @@ +/* + * 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 . + */ + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +Future showPwaInstallDialog(BuildContext context, WidgetRef ref) async { + final manifest = ref.read(currentTabManifestProvider); + final name = manifest?.name ?? manifest?.shortName ?? 'this web app'; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add to Home Screen'), + content: Text('Add "$name" to your home screen?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Add'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final success = await ref.read(installCurrentWebAppProvider.future); + + if (context.mounted) { + if (success) { + showInfoMessage(context, '$name added to home screen'); + } else { + showErrorMessage( + context, + 'Failed to add $name. The site may not support installation.', + ); + } + } + } 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 (e is StateError) { + errorMessage = 'No tab selected. Please try again.'; + } + + showErrorMessage(context, errorMessage); + } + } + } +} diff --git a/app/lib/features/user/domain/repositories/profile.dart b/app/lib/features/user/domain/repositories/profile.dart index ea08e1cc..8457e4a0 100644 --- a/app/lib/features/user/domain/repositories/profile.dart +++ b/app/lib/features/user/domain/repositories/profile.dart @@ -62,6 +62,12 @@ class ProfileRepository extends _$ProfileRepository { return false; } + // Clean up Mozilla cache directories before deleting the profile + final mozillaProfileIds = filesystem.getMozillaProfileIds(uuid); + for (final profileId in mozillaProfileIds) { + await filesystem.clearMozillaProfileCache(profileId); + } + await filesystem.getProfileDir(uuid).delete(recursive: true); ref.invalidateSelf(); diff --git a/app/lib/features/user/domain/repositories/profile.g.dart b/app/lib/features/user/domain/repositories/profile.g.dart index e22ee8a4..0f7ea275 100644 --- a/app/lib/features/user/domain/repositories/profile.g.dart +++ b/app/lib/features/user/domain/repositories/profile.g.dart @@ -33,7 +33,7 @@ final class ProfileRepositoryProvider ProfileRepository create() => ProfileRepository(); } -String _$profileRepositoryHash() => r'249240ce0e775d4fd9ee3558e6b05326d09d79de'; +String _$profileRepositoryHash() => r'e925dba74b0f15244fea8fff8391b5b67509be09'; abstract class _$ProfileRepository extends $AsyncNotifier> { FutureOr> build(); diff --git a/app/lib/features/user/domain/services/user_backup.dart b/app/lib/features/user/domain/services/user_backup.dart index fe52cee2..f18b1a57 100644 --- a/app/lib/features/user/domain/services/user_backup.dart +++ b/app/lib/features/user/domain/services/user_backup.dart @@ -51,6 +51,8 @@ class UserBackupService extends _$UserBackupService { Stream getBackupListStream() async* { final backupDirectory = await getBackupDirectory(); + if (!await backupDirectory.exists()) return; + await for (final entity in backupDirectory.list(recursive: true)) { if (entity is File) { yield entity; @@ -144,32 +146,49 @@ class UserBackupService extends _$UserBackupService { final existingProfile = await filesystem.readProfileMetadata( outputDirectory, ); - if (existingProfile != null) { - if (existingProfile.uuidValue == filesystem.selectedProfile) { - throw Exception( - 'Unable to override active User, please switch to another User and try again', - ); - } - - final profileDir = filesystem.getProfileDir( - existingProfile.uuidValue, + if (existingProfile == null) { + throw Exception( + 'Backup does not contain valid profile metadata', ); + } - if (await profileDir.exists()) { - final result = await confirmOverrideCallback(); + if (existingProfile.uuidValue == filesystem.selectedProfile) { + throw Exception( + 'Unable to override active User, please switch to another User and try again', + ); + } - if (result == true) { - await profileDir.delete(recursive: true); - await outputDirectory.rename(profileDir.path); - } + final profileDir = filesystem.getProfileDir( + existingProfile.uuidValue, + ); + + if (await profileDir.exists()) { + final result = await confirmOverrideCallback(); + + if (result == true) { + await profileDir.delete(recursive: true); + await outputDirectory.rename(profileDir.path); } + } else { + // Profile doesn't exist yet, just move the restored data into place + await outputDirectory.rename(profileDir.path); } }); ref.invalidate(profileRepositoryProvider); return true; } finally { - await outputDirectory.delete(recursive: true); + try { + if (await outputDirectory.exists()) { + await outputDirectory.delete(recursive: true); + } + } catch (e, s) { + logger.w( + 'Failed to cleanup temporary backup directory: ${outputDirectory.path}', + error: e, + stackTrace: s, + ); + } } } diff --git a/app/lib/features/user/domain/services/user_backup.g.dart b/app/lib/features/user/domain/services/user_backup.g.dart index d236859f..384392a6 100644 --- a/app/lib/features/user/domain/services/user_backup.g.dart +++ b/app/lib/features/user/domain/services/user_backup.g.dart @@ -41,7 +41,7 @@ final class UserBackupServiceProvider } } -String _$userBackupServiceHash() => r'cbeffa04bb52d3c7d50a2eb38678750f2a0362cc'; +String _$userBackupServiceHash() => r'a0bcf458e6b0a982f1fc90fce99e60cc3418f4d9'; abstract class _$UserBackupService extends $Notifier { void build(); diff --git a/app/lib/utils/filesystem.dart b/app/lib/utils/filesystem.dart index 3517e35e..b6307649 100644 --- a/app/lib/utils/filesystem.dart +++ b/app/lib/utils/filesystem.dart @@ -75,6 +75,20 @@ Future clearMozillaProfileCache(String profileId) async { } } +/// Returns the list of Mozilla profile IDs (`.default` directory names) +/// inside the given profile directory's `mozilla/` subdirectory. +List getMozillaProfileIds(Directory profileDir) { + final mozillaDir = Directory(p.join(profileDir.path, 'mozilla')); + if (!mozillaDir.existsSync()) return []; + + return mozillaDir + .listSync() + .whereType() + .map((dir) => p.basename(dir.path)) + .where((name) => name.endsWith('.default')) + .toList(); +} + Future> getProfilesWithDuplicateMozillaProfiles( Directory profilesDir, ) async { @@ -122,6 +136,16 @@ Future selectStartupProfile(Directory profilesDir) async { var startupProfile = await readStartupProfile(profilesDir); final availableProfiles = await getAvailableProfileDirectories(profilesDir); + // Verify the startup profile directory actually exists + if (startupProfile != null) { + final profileDir = getProfileDir(profilesDir, startupProfile); + final exists = availableProfiles.any((dir) => dir.path == profileDir.path); + if (!exists) { + logger.w('Startup profile directory missing, selecting fallback'); + startupProfile = null; + } + } + if (startupProfile == null) { final sortedDirs = await sortByAccessTime(availableProfiles); diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle index 848a016e..8d76aa71 100644 --- a/packages/flutter_mozilla_components/android/build.gradle +++ b/packages/flutter_mozilla_components/android/build.gradle @@ -127,6 +127,8 @@ dependencies { implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-webauthn:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-pwa:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-intent:$mozillaComponentsVersion" implementation "org.mozilla.components:ui-widgets:$mozillaComponentsVersion" implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion" @@ -134,6 +136,8 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'com.google.android.material:material:1.13.0' + implementation 'com.mikepenz:iconics-core:5.4.0' + implementation 'com.mikepenz:community-material-typeface:7.0.96.1-kotlin@aar' //https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo implementation 'androidx.activity:activity-ktx:1.12.3' implementation 'androidx.paging:paging-runtime-ktx:3.4.0' diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index 7668220b..c3eb0601 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -73,7 +73,7 @@ import mozilla.components.support.webextensions.WebExtensionPopupObserver */ @SuppressWarnings("LargeClass") abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler { - private val sessionFeature = ViewBoundFeatureWrapper() + protected val sessionFeature = ViewBoundFeatureWrapper() private val shareResourceFeature = ViewBoundFeatureWrapper() private val downloadsFeature = ViewBoundFeatureWrapper() private val appLinksFeature = ViewBoundFeatureWrapper() @@ -136,6 +136,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit protected abstract fun createEngine(components: Components): EngineView + // Track this fragment's EngineView instance to reassign singleton when fragment becomes active + private var fragmentEngineView: EngineView? = null + private lateinit var requestDownloadPermissionsLauncher: ActivityResultLauncher> private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher> private lateinit var requestPromptsPermissionsLauncher: ActivityResultLauncher> @@ -219,6 +222,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit ProfileContext(requireContext(), components.profileApplicationContext.relativePath) val engineView = createEngine(components) + fragmentEngineView = engineView // Track for lifecycle management val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity()) ?.let { ProfileContext(it, components.profileApplicationContext.relativePath) } val engineNativeView = engineView.asView() @@ -228,17 +232,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit binding.swipeToRefresh.addView(engineNativeView) - components.engineView = engineView - - // Apply any pending viewport settings that were set before engineView was ready - GlobalComponents.viewportApi?.applyPendingSettings() + components.activeEngineView = engineView sessionFeature.set( feature = SessionFeature( components.core.store, components.useCases.sessionUseCases.goBack, components.useCases.sessionUseCases.goForward, - components.engineView!!, + engineView, sessionId, ), owner = this, @@ -468,7 +469,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit thumbnailsFeature.set( feature = BrowserThumbnails( profileContext, - components.engineView!!, + engineView, components.core.store ), owner = this, @@ -496,12 +497,20 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit } } + onEngineSetupComplete() + } catch (e: Exception) { Log.e("EngineCreation", "Failed to create engine: ${e.message}", e) context?.let { restartApp(it) } } } + /** + * Called after the engine view is fully set up and added to the view hierarchy. + * Subclasses can override to perform additional setup that requires an attached engine view. + */ + protected open fun onEngineSetupComplete() {} + private fun openPopup(webExtensionState: WebExtensionState) { val store = components.core.store val popupSession = store.state.extensions[webExtensionState.id]?.popupSession ?: return @@ -587,6 +596,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit } } + override fun onResume() { + super.onResume() + // Reassign active engine view to this fragment's EngineView when fragment becomes active + fragmentEngineView?.let { + components.activeEngineView = it + } + } + override fun onDestroyView() { super.onDestroyView() @@ -595,7 +612,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit keyboardVisibilityFeature = null GlobalComponents.onPullToRefreshEnabledChanged = null - components.engineView?.setActivityContext(null) + val engineView = fragmentEngineView + engineView?.setActivityContext(null) + if (components.activeEngineView == engineView) { + components.activeEngineView = null + } _binding = null + fragmentEngineView = null } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt index f47aad92..bb7bcfb6 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BrowserFragment.kt @@ -21,6 +21,8 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler { //We cannot introduce here our wrapped context since a activity type is required to make features work correctly like context menu return components.core.engine.createView(requireContext()).apply { selectionActionDelegate = components.selectionAction + }.also { engineView -> + components.mainBrowserEngineView = engineView } } @@ -35,9 +37,18 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler { super.onViewCreated(view, savedInstanceState) } + override fun onEngineSetupComplete() { + GlobalComponents.viewportApi?.applyPendingToolbarHeight() + } + override fun onBackPressed(): Boolean = super.readerViewFeature.onBackPressed() || super.onBackPressed() + override fun onDestroyView() { + super.onDestroyView() + components.mainBrowserEngineView = null + } + companion object { fun create(sessionId: String? = null) = BrowserFragment().apply { arguments = Bundle().apply { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt index b3bdbbfc..234f7f90 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt @@ -44,12 +44,16 @@ class Components(val profileApplicationContext: ProfileContext, ) { val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) } val events by lazy { Events(flutterEvents) } - val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store) } + val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store, core.webAppShortcutManager) } val services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) } val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) } val search by lazy { Search(profileApplicationContext, core, useCases) } - var engineView: EngineView? = null + var mainBrowserEngineView: EngineView? = null + var externalAppEngineView: EngineView? = null + + var activeEngineView: EngineView? = null + var engineReportedInitialized = false private val notificationManagerCompat = NotificationManagerCompat.from(profileApplicationContext) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt new file mode 100644 index 00000000..fea66766 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ExternalAppBrowserFragment.kt @@ -0,0 +1,371 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import android.content.Intent +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.PopupWindow +import androidx.annotation.CallSuper +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.color.MaterialColors +import com.google.android.material.materialswitch.MaterialSwitch +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.IIcon +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbar +import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbarFeature +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.state.ExternalAppType +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.customtabs.CustomTabWindowFeature +import mozilla.components.feature.pwa.feature.ManifestUpdateFeature +import mozilla.components.feature.pwa.feature.WebAppActivityFeature +import mozilla.components.feature.pwa.feature.WebAppContentFeature +import mozilla.components.feature.pwa.feature.WebAppHideToolbarFeature +import mozilla.components.feature.pwa.feature.WebAppSiteControlsFeature +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.arch.lifecycle.addObservers + +/** + * Fragment used for browsing the web within external apps (Custom Tabs and PWAs). + * Extends [BaseBrowserFragment] with Custom Tab toolbar features and PWA support. + */ +class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler { + + private val customTabsToolbarFeature = ViewBoundFeatureWrapper() + private val hideToolbarFeature = ViewBoundFeatureWrapper() + private val windowFeature = ViewBoundFeatureWrapper() + + private var customTabToolbar: CustomTabToolbar? = null + private var activePopup: PopupWindow? = null + + private val customTabSessionId: String? + get() = arguments?.getString(CUSTOM_TAB_SESSION_ID_KEY) + + private val webAppManifestUrl: String? + get() = arguments?.getString(WEB_APP_MANIFEST_URL_KEY) + + override fun createEngine(components: Components): EngineView { + return components.core.engine.createView(requireContext()).apply { + selectionActionDelegate = components.selectionAction + }.also { engineView -> + components.externalAppEngineView = engineView + } + } + + @Suppress("LongMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val sessionId = customTabSessionId ?: return + + view.post { + if (GlobalComponents.components == null) return@post + initializeCustomTabFeatures(view, sessionId) + } + } + + override fun onEngineSetupComplete() { + val sessionId = customTabSessionId ?: return + val store = components.core.store + val customTab = store.state.findCustomTab(sessionId) ?: return + + val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP || + customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY + + if (!isPwaOrTwa) { + setupCustomTabToolbar(sessionId) + } + } + + private fun setupCustomTabToolbar(sessionId: String) { + val view = requireView() + + val toolbar = CustomTabToolbar(requireContext()).apply { + layoutParams = AppBarLayout.LayoutParams( + AppBarLayout.LayoutParams.MATCH_PARENT, + AppBarLayout.LayoutParams.WRAP_CONTENT + ) + } + customTabToolbar = toolbar + + binding.customTabAppBar.apply { + removeAllViews() + addView(toolbar) + visibility = View.VISIBLE + } + + toolbar.onCloseListener = { + requireActivity().finishAndRemoveTask() + } + toolbar.onShareListener = { + shareCurrentUrl(sessionId) + } + toolbar.onOpenInBrowserListener = { + openInBrowser(sessionId) + } + toolbar.onMenuListener = { + showCustomTabMenu(sessionId) + } + + customTabsToolbarFeature.set( + feature = CustomTabToolbarFeature( + store = components.core.store, + toolbar = toolbar, + sessionId = sessionId, + window = requireActivity().window + ), + owner = this, + view = view + ) + } + + private fun showCustomTabMenu(sessionId: String) { + val toolbar = customTabToolbar ?: return + val store = components.core.store + val customTab = store.state.findCustomTab(sessionId) ?: return + val anchorView = toolbar.getMenuButton() + + val menuView = LayoutInflater.from(requireContext()) + .inflate(R.layout.custom_tab_menu, null) + + val popup = PopupWindow( + menuView, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + true + ).apply { + elevation = 8f + isOutsideTouchable = true + } + activePopup = popup + + val iconColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurface) + val disabledColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurfaceVariant) + + // Navigation row icons + val canGoBack = customTab.content.canGoBack + val canGoForward = customTab.content.canGoForward + + val backBtn = menuView.findViewById(R.id.menuBack) + val forwardBtn = menuView.findViewById(R.id.menuForward) + + backBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_left, 20, if (canGoBack) iconColor else disabledColor)) + backBtn.isEnabled = canGoBack + backBtn.alpha = if (canGoBack) 1.0f else 0.38f + backBtn.setOnClickListener { + components.useCases.sessionUseCases.goBack(sessionId) + popup.dismiss() + } + + forwardBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_right, 20, if (canGoForward) iconColor else disabledColor)) + forwardBtn.isEnabled = canGoForward + forwardBtn.alpha = if (canGoForward) 1.0f else 0.38f + forwardBtn.setOnClickListener { + components.useCases.sessionUseCases.goForward(sessionId) + popup.dismiss() + } + + // Menu item icons + menuView.findViewById(R.id.menuRefreshIcon) + .setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_refresh, 20, iconColor)) + menuView.findViewById(R.id.menuShareIcon) + .setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 20, iconColor)) + menuView.findViewById(R.id.menuDesktopIcon) + .setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_monitor, 20, iconColor)) + menuView.findViewById(R.id.menuOpenInBrowserIcon) + .setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 20, iconColor)) + + // Refresh + menuView.findViewById(R.id.menuRefresh).setOnClickListener { + components.useCases.sessionUseCases.reload(sessionId) + popup.dismiss() + } + + // Share + menuView.findViewById(R.id.menuShare).setOnClickListener { + shareCurrentUrl(sessionId) + popup.dismiss() + } + + // Desktop site toggle + val isDesktop = customTab.content.desktopMode + val desktopSwitch = menuView.findViewById(R.id.menuDesktopSwitch) + desktopSwitch.isChecked = isDesktop + val desktopRow = menuView.findViewById(R.id.menuDesktopSite) + desktopRow.setOnClickListener { + val newState = !desktopSwitch.isChecked + components.useCases.sessionUseCases.requestDesktopSite(newState, sessionId) + popup.dismiss() + } + desktopSwitch.setOnCheckedChangeListener { _, isChecked -> + components.useCases.sessionUseCases.requestDesktopSite(isChecked, sessionId) + popup.dismiss() + } + + // Open in browser + menuView.findViewById(R.id.menuOpenInBrowser).setOnClickListener { + openInBrowser(sessionId) + popup.dismiss() + } + + popup.showAsDropDown(anchorView, 0, 0, Gravity.END) + } + + private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable { + return IconicsDrawable(requireContext(), icon).apply { + this.sizeDp = sizeDp + this.colorInt = color + } + } + + private fun openInBrowser(sessionId: String) { + val activity = requireActivity() + + sessionFeature?.get()?.release() + components.useCases.customTabsUseCases.migrate(sessionId, select = true) + + val mainIntent = activity.packageManager.getLaunchIntentForPackage(activity.packageName) + mainIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + mainIntent?.let { activity.startActivity(it) } + + activity.finishAndRemoveTask() + } + + private fun shareCurrentUrl(sessionId: String) { + val store = components.core.store + store.state.findCustomTab(sessionId)?.let { tab -> + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, tab.content.url) + putExtra(Intent.EXTRA_SUBJECT, tab.content.title) + } + startActivity(Intent.createChooser(shareIntent, null)) + } + } + + private fun initializeCustomTabFeatures(view: View, sessionId: String) { + val activity = requireActivity() + val store = components.core.store + val customTab = store.state.findCustomTab(sessionId) ?: return + + components.activeEngineView?.setDynamicToolbarMaxHeight(0) + + val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url -> + components.core.webAppManifestStorage.getManifestCache(url) + } + + windowFeature.set( + feature = CustomTabWindowFeature(activity, store, sessionId), + owner = this, + view = view, + ) + + val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP || + customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY + + if (isPwaOrTwa) { + hideToolbarFeature.set( + feature = WebAppHideToolbarFeature( + store = store, + customTabsStore = components.core.customTabsStore, + tabId = sessionId, + manifest = manifest, + ) { toolbarVisible -> + Logger.debug("Custom tab toolbar visibility: $toolbarVisible") + }, + owner = this, + view = view, + ) + } + + if (manifest != null) { + activity.lifecycle.addObservers( + WebAppActivityFeature( + activity, + components.core.icons, + manifest, + ), + WebAppContentFeature( + store = store, + tabId = sessionId, + manifest, + ), + ManifestUpdateFeature( + activity.applicationContext, + store, + components.core.webAppShortcutManager, + components.core.webAppManifestStorage, + sessionId, + manifest, + ), + ) + viewLifecycleOwner.lifecycle.addObserver( + WebAppSiteControlsFeature( + activity.applicationContext, + store, + components.useCases.sessionUseCases.reload, + sessionId, + manifest, + notificationsDelegate = components.notificationsDelegate, + ), + ) + } + } + + @Deprecated("Deprecated in Java") + @CallSuper + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, data, resultCode) + } + + override fun onBackPressed(): Boolean { + val sessionId = customTabSessionId ?: return super.onBackPressed() + + val tab = components.core.store.state.findCustomTab(sessionId) + if (tab?.content?.canGoBack == true) { + components.useCases.sessionUseCases.goBack(sessionId) + return true + } + + requireActivity().finishAndRemoveTask() + return true + } + + override fun onDestroyView() { + super.onDestroyView() + activePopup?.dismiss() + activePopup = null + customTabToolbar = null + components.externalAppEngineView = null + } + + companion object { + private const val CUSTOM_TAB_SESSION_ID_KEY = "custom_tab_session_id" + private const val WEB_APP_MANIFEST_URL_KEY = "web_app_manifest_url" + + fun create( + customTabSessionId: String, + webAppManifestUrl: String? = null, + ) = ExternalAppBrowserFragment().apply { + arguments = Bundle().apply { + putSessionId(customTabSessionId) + putString(CUSTOM_TAB_SESSION_ID_KEY, customTabSessionId) + putString(WEB_APP_MANIFEST_URL_KEY, webAppManifestUrl) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt index ea1193d9..f064b3e5 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt @@ -2,6 +2,7 @@ package eu.weblibre.flutter_mozilla_components import android.content.Context import android.content.ContextWrapper +import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.os.Build import androidx.annotation.RequiresApi @@ -13,6 +14,8 @@ class ProfileContext(private val base: Context, val relativePath: String) : private val subfolderRoot = File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default + private val profilePrefix = File(relativePath).name + private var customFilesDir: File = File(subfolderRoot, "files") private var customNoBackupFilesDir: File = File(subfolderRoot, "no_backup") private var customObbDir: File = File(subfolderRoot, "obb") @@ -124,4 +127,8 @@ class ProfileContext(private val base: Context, val relativePath: String) : parentFile?.mkdirs() } } + + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + return base.getSharedPreferences("${profilePrefix}_$name", mode) + } } 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 new file mode 100644 index 00000000..4d0d37f7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +object PwaConstants { + // Intent extras keys for PWA metadata + const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid" + const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id" + + // Profile and file paths + const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile" + const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping" + + // Component initialization timeouts + const val COMPONENT_INIT_TIMEOUT_MS = 10000L + const val COMPONENT_INIT_CHECK_INTERVAL_MS = 100L +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt new file mode 100644 index 00000000..2ff1d344 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt @@ -0,0 +1,252 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.log.logger.Logger + +/** + * Native activity that hosts [ExternalAppBrowserFragment] for Custom Tab and PWA sessions. + * This is a non-Flutter activity — it renders GeckoView directly in a native layout. + * + * Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app. + */ +class ExternalAppBrowserActivity : AppCompatActivity() { + + private val logger = Logger("ExternalAppBrowserActivity") + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var loadingScreenManager: LoadingScreenManager? = null + + private val customTabSessionId: String? + get() = intent?.getStringExtra(EXTRA_CUSTOM_TAB_SESSION_ID) + + private val webAppManifestUrl: String? + get() = intent?.getStringExtra(EXTRA_WEB_APP_MANIFEST_URL) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sessionId = customTabSessionId + if (sessionId == null) { + logger.error("No custom tab session ID provided, finishing.") + finish() + return + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + setContentView(R.layout.activity_external_app_browser) + + val components = GlobalComponents.components + if (components == null) { + logger.debug("Components not yet initialized, waiting...") + showLoading() + waitForComponents(sessionId) + return + } + + showFragment(sessionId) + } + + private fun showLoading() { + val container = findViewById(R.id.container) + loadingScreenManager = LoadingScreenManager.forActivity(this, container) + + // Show branded placeholder immediately based on available data + // If we have a manifest URL, it's likely a PWA + val url = webAppManifestUrl ?: "" + if (url.isNotEmpty()) { + // Try to show PWA placeholder + loadingScreenManager?.showLoadingForIntent( + Intent().apply { + data = android.net.Uri.parse(url) + putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder") + } + ) + } else { + // Show Custom Tab placeholder + loadingScreenManager?.showLoadingForIntent(Intent()) + } + } + + private fun waitForComponents(sessionId: String) { + coroutineScope.launch { + var elapsedMs = 0L + + while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) { + val components = GlobalComponents.components + if (components != null) { + // Enhance the existing loading screen with actual data + enhanceLoadingScreen(components, sessionId) + // Brief delay to show the enhanced loading screen + delay(200) + showFragment(sessionId) + return@launch + } + + delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS) + elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS + } + + // Timeout reached + if (isActive) { + logger.error("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms") + finish() + } + } + } + + /** + * Enhances the existing loading screen with actual data once components are ready. + */ + private fun enhanceLoadingScreen(components: Components, sessionId: String) { + val session = components.core.store.state.findCustomTab(sessionId) ?: return + val url = session.content.url + val manifestUrl = webAppManifestUrl + + loadingScreenManager?.let { manager -> + when (session.config.externalAppType) { + mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP, + mozilla.components.browser.state.state.ExternalAppType.TRUSTED_WEB_ACTIVITY -> { + // Enhance PWA loading with manifest data + coroutineScope.launch(Dispatchers.IO) { + val manifest = manifestUrl?.let { manifestUrl -> + components.core.webAppManifestStorage.loadManifest(manifestUrl) + } + + withContext(Dispatchers.Main) { + manifest?.let { + manager.enhancePwaLoading(it, components.core.icons, coroutineScope) + } ?: manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope) + } + } + } + else -> { + // Enhance Custom Tab loading with favicon + manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope) + } + } + } + } + + private fun showFragment(sessionId: String) { + val components = GlobalComponents.components ?: run { + logger.error("Components still null after waiting, finishing.") + finish() + return + } + + // Verify session exists + if (components.core.store.state.findCustomTab(sessionId) == null) { + logger.error("Custom tab session $sessionId not found in store, finishing.") + finish() + return + } + + val fragment = ExternalAppBrowserFragment.create( + customTabSessionId = sessionId, + webAppManifestUrl = webAppManifestUrl, + ) + + supportFragmentManager.beginTransaction() + .replace(R.id.container, fragment) + .runOnCommit { loadingScreenManager?.hideLoading() } + .commit() + } + + override fun onResume() { + super.onResume() + + // If the session was removed while we were in the background, finish + val sessionId = customTabSessionId ?: return + val components = GlobalComponents.components ?: return + if (components.core.store.state.findCustomTab(sessionId) == null) { + logger.debug("Custom tab session $sessionId gone, finishing activity.") + finish() + } + } + + override fun onDestroy() { + super.onDestroy() + + // Cancel any pending coroutines + coroutineScope.cancel() + + // Clean up loading screen manager + loadingScreenManager?.cleanup() + loadingScreenManager = null + + // Only clean up when the activity is actually finishing (user closed it), + // not when the system temporarily destroys it (e.g. switching to main app). + if (isFinishing) { + val sessionId = customTabSessionId + if (sessionId != null) { + val components = GlobalComponents.components + if (components != null) { + val customTab = components.core.store.state.findCustomTab(sessionId) + if (customTab != null) { + components.useCases.customTabsUseCases.remove(sessionId) + } + } + } + } + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + val fragment = supportFragmentManager.findFragmentById(R.id.container) + if (fragment is UserInteractionHandler && fragment.onBackPressed()) { + return + } + super.onBackPressed() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + val fragment = supportFragmentManager.findFragmentById(R.id.container) + if (fragment is ExternalAppBrowserFragment) { + fragment.onPictureInPictureModeChanged(isInPictureInPictureMode) + } + } + + companion object { + const val EXTRA_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id" + const val EXTRA_WEB_APP_MANIFEST_URL = "web_app_manifest_url" + + fun createIntent( + context: Context, + customTabSessionId: String, + webAppManifestUrl: String? = null, + ): Intent { + return Intent(context, ExternalAppBrowserActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId) + webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) } + } + } + } +} 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 new file mode 100644 index 00000000..9d4c48ab --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt @@ -0,0 +1,385 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.activities + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.os.Bundle +import android.widget.FrameLayout +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.feature.customtabs.CustomTabIntentProcessor +import mozilla.components.feature.intent.ext.getSessionId +import mozilla.components.feature.pwa.intent.WebAppIntentProcessor +import mozilla.components.support.base.log.logger.Logger +import java.io.File + +/** + * Lightweight transparent activity that receives all ACTION_VIEW intents and routes them + * to the appropriate activity: + * - Custom Tab intents → [ExternalAppBrowserActivity] + * - PWA launch intents → [ExternalAppBrowserActivity] (with profile/context tracking) + * - Regular VIEW intents → MainActivity (Flutter) + * + * For PWA intents created by our custom installer, checks profile match and shows dialog + * if the current profile differs from the installation profile. + */ +class IntentReceiverActivity : Activity() { + + private val logger = Logger("IntentReceiverActivity") + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var pendingIntent: Intent? = null + private var loadingScreenManager: LoadingScreenManager? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val intent = intent?.let { Intent(it) } ?: Intent() + + logger.debug("onCreate: action=${intent.action} data=${intent.dataString}") + + // Strip flags that could interfere with task management + intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv() + intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv() + + processIntent(intent) + } + + override fun onDestroy() { + super.onDestroy() + coroutineScope.cancel() + loadingScreenManager?.cleanup() + loadingScreenManager = null + } + + private fun processIntent(intent: Intent) { + val components = GlobalComponents.components + if (components == null) { + logger.warn("Components not initialized, waiting for initialization...") + pendingIntent = intent + showLoadingIndicator(intent) + waitForComponentsWithTimeout() + return + } + + routeIntent(intent) + } + + private fun routeIntent(intent: Intent) { + // Check if this is our custom PWA intent with profile metadata + val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) + val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID) + if (profileUuid != null) { + logger.debug("PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId") + handlePwaIntent(intent, profileUuid, contextId) + return + } + + // Fall back to standard intent processors for Custom Tabs and legacy PWAs + val components = GlobalComponents.components + ?: run { + logger.error("Components became null during routing") + handleRegularIntent(intent) + return + } + + val processors = listOf( + "CustomTab" to CustomTabIntentProcessor( + components.useCases.customTabsUseCases.add, + resources, + isPrivate = false, + ), + "PWA" to WebAppIntentProcessor( + components.core.store, + components.useCases.customTabsUseCases.addWebApp, + components.useCases.sessionUseCases.loadUrl, + components.core.webAppManifestStorage, + ), + ) + + for ((name, processor) in processors) { + logger.debug("Trying $name processor...") + try { + val result = processor.process(intent) + logger.debug("$name processor result: $result") + if (result) { + val sessionId = intent.getSessionId() + logger.debug("$name session ID from intent: $sessionId") + if (sessionId != null) { + val externalIntent = ExternalAppBrowserActivity.createIntent( + context = this, + customTabSessionId = sessionId, + webAppManifestUrl = if (name == "PWA") intent.dataString else null, + ) + startActivity(externalIntent) + finish() + return + } else { + logger.warn("$name processor succeeded but no session ID in intent!") + } + } + } catch (e: Exception) { + logger.error("Error in $name processor", e) + } + } + + logger.debug("No processor matched, routing to MainActivity") + handleRegularIntent(intent) + } + + /** + * Handles PWA intents with profile and context metadata. + * Checks if current profile matches and shows dialog if different. + */ + private fun handlePwaIntent( + intent: Intent, + profileUuid: String, + contextId: String?, + ) { + val url = intent.dataString + if (url == null) { + logger.error("PWA intent has no URL") + handleRegularIntent(intent) + return + } + + val currentProfileUuid = getCurrentProfileUuid() + + if (currentProfileUuid != null && currentProfileUuid != profileUuid) { + logger.debug("Profile mismatch: current=$currentProfileUuid, expected=$profileUuid") + showProfileMismatchDialog(url, contextId) + } else { + logger.debug("Profile match or indeterminate, launching PWA with contextId=$contextId") + launchPwaWithContext(url, contextId) + } + } + + /** + * Reads the current profile UUID from the filesystem. + * The Flutter side persists this as a plain text file at: + * /weblibre_profiles/current_profile + */ + private fun getCurrentProfileUuid(): String? { + return try { + val startupProfileFile = File(filesDir, PwaConstants.CURRENT_PROFILE_FILE) + if (startupProfileFile.exists()) { + startupProfileFile.readText().trim().ifEmpty { null } + } else { + null + } + } catch (e: Exception) { + logger.error("Failed to read current profile UUID", e) + null + } + } + + /** + * Shows a dialog when the current profile doesn't match the PWA's installation profile. + */ + private fun showProfileMismatchDialog( + url: String, + contextId: String?, + ) { + val message = "This PWA was originally installed in a different profile. " + + "Opening it here will use your current profile's data and settings, " + + "which means you won't see the same content, preferences, or saved data " + + "that you had in the original profile.\n\n" + + "Do you want to proceed anyway?" + + AlertDialog.Builder(this) + .setTitle("PWA Profile Mismatch") + .setMessage(message) + .setPositiveButton("Open Anyway") { _, _ -> + logger.debug("User chose to open PWA despite profile mismatch") + launchPwaWithContext(url, contextId) + } + .setNegativeButton("Cancel") { _, _ -> + logger.debug("User cancelled PWA launch due to profile mismatch") + finish() + } + .setOnCancelListener { + finish() + } + .show() + } + + /** + * Launches the PWA with the specified context ID for storage isolation. + */ + private fun launchPwaWithContext(url: String, contextId: String?) { + val components = GlobalComponents.components + ?: run { + logger.error("Components not available for PWA launch") + handleRegularIntent(intent) + return + } + + coroutineScope.launch { + try { + val manifest = withContext(Dispatchers.IO) { + components.core.webAppManifestStorage.loadManifest(url) + } + + val sessionId = createPwaSession( + url = url, + contextId = contextId, + manifest = manifest + ) + + logger.debug("Created PWA session: contextId=$contextId, sessionId=$sessionId") + + val externalIntent = ExternalAppBrowserActivity.createIntent( + context = this@IntentReceiverActivity, + customTabSessionId = sessionId, + webAppManifestUrl = url, + ) + startActivity(externalIntent) + finish() + } catch (e: Exception) { + logger.error("Failed to launch PWA with context", e) + handleRegularIntent(intent) + } + } + } + + /** + * Creates a custom tab session for a PWA with the specified context ID. + */ + private fun createPwaSession( + url: String, + contextId: String?, + manifest: mozilla.components.concept.engine.manifest.WebAppManifest? + ): String { + val components = GlobalComponents.components + ?: throw IllegalStateException("Components not initialized") + + val customTabConfig = mozilla.components.browser.state.state.CustomTabConfig( + externalAppType = mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP + ) + + val tab = mozilla.components.browser.state.state.createCustomTab( + url = url, + contextId = contextId, + config = customTabConfig, + webAppManifest = manifest, + source = mozilla.components.browser.state.state.SessionState.Source.Internal.CustomTab, + private = false + ) + + components.core.store.dispatch( + mozilla.components.browser.state.action.CustomTabListAction.AddCustomTabAction(tab) + ) + + val loadUrlFlags = mozilla.components.concept.engine.EngineSession.LoadUrlFlags.external() + components.useCases.sessionUseCases.loadUrl(url, tab.id, loadUrlFlags) + + return tab.id + } + + /** + * Shows a branded loading screen immediately based on intent type. + * This avoids showing a minimal spinner and shows proper placeholders right away. + */ + private fun showLoadingIndicator(intent: Intent) { + // Create a container layout + val container = FrameLayout(this).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + setContentView(container) + + // Initialize loading screen manager and show branded screen immediately + loadingScreenManager = LoadingScreenManager.forActivity(this, container) + loadingScreenManager?.showLoadingForIntent(intent) + } + + /** + * Enhances the existing loading screen with actual data once components are initialized. + * This updates the placeholder with real manifest/icon data. + */ + private fun enhanceLoadingScreen(intent: Intent) { + val components = GlobalComponents.components ?: return + val url = intent.dataString ?: return + + loadingScreenManager?.let { manager -> + when { + // PWA intent - enhance with manifest data + LoadingScreenManager.isPwaIntent(intent) -> { + coroutineScope.launch { + val manifest = components.core.webAppManifestStorage.loadManifest(url) + manifest?.let { + manager.enhancePwaLoading(it, components.core.icons, coroutineScope) + } + } + } + // Custom Tab - enhance with favicon + else -> { + manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope) + } + } + } + } + + /** + * Waits for GlobalComponents to be initialized with a timeout. + * Once components are ready, shows branded loading screen before routing. + * Falls back to MainActivity if timeout is reached (10 seconds). + */ + private fun waitForComponentsWithTimeout() { + coroutineScope.launch { + var elapsedMs = 0L + + while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) { + if (GlobalComponents.components != null) { + logger.debug("Components initialized after ${elapsedMs}ms") + pendingIntent?.let { intent -> + // Enhance the existing loading screen with actual data + enhanceLoadingScreen(intent) + // Small delay to show the enhanced loading screen (200ms) + delay(200) + routeIntent(intent) + } + pendingIntent = null + return@launch + } + + delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS) + elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS + } + + if (isActive) { + logger.warn("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms, falling back to MainActivity") + pendingIntent?.let { intent -> + handleRegularIntent(intent) + } + pendingIntent = null + } + } + } + + private fun handleRegularIntent(intent: Intent) { + val mainActivityIntent = Intent(intent).apply { + setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(mainActivityIntent) + finish() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 56bd7bae..310bb1d4 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -40,6 +40,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi @@ -275,6 +276,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl()) GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext)) + // PWA API for web app installation and management + GeckoPwaApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPwaApiImpl(profileApplicationContext)) + // Viewport API for dynamic toolbar and keyboard handling val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger) val viewportApi = GeckoViewportApiImpl() 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 new file mode 100644 index 00000000..af0bff4a --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt @@ -0,0 +1,324 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import androidx.core.content.getSystemService +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity +import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi +import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon +import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.support.base.log.logger.Logger +import java.io.File +import java.security.MessageDigest + +/** + * Implementation of GeckoPwaApi that provides PWA install and query functionality. + * + * Creates custom shortcuts with profile and container metadata embedded in intent extras, + * ensuring PWAs reopen with the same profile and container context. + */ +class GeckoPwaApiImpl( + private val context: Context +) : GeckoPwaApi { + companion object { + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + private val logger = Logger("GeckoPwaApiImpl") + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun installWebApp( + tabId: String?, + profileUuid: String, + contextId: String?, + callback: (Result) -> Unit + ) { + logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId") + coroutineScope.launch { + try { + val store = components.core.store + val tab = if (tabId != null) { + store.state.findTab(tabId) + } else { + store.state.selectedTab + } + + if (tab == null) { + logger.warn("Tab not found for installWebApp: $tabId") + callback(Result.success(false)) + return@launch + } + + val manifest = tab.content.webAppManifest + if (manifest == null) { + logger.warn("No manifest found for tab ${tab.id}") + callback(Result.success(false)) + return@launch + } + + logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}") + + val success = createPwaShortcut( + manifest = manifest, + profileUuid = profileUuid, + contextId = contextId, + ) + + if (success) { + components.core.webAppManifestStorage.saveManifest(manifest) + storeProfileMapping(manifest.startUrl, profileUuid) + logger.debug("Web app installation completed for tab ${tab.id}") + } else { + logger.warn("Failed to create PWA shortcut for tab ${tab.id}") + } + + callback(Result.success(success)) + } catch (e: Exception) { + logger.error("Failed to install web app", e) + callback(Result.failure(e)) + } + } + } + + /** + * Creates a PWA shortcut with profile and container metadata in intent extras. + */ + private suspend fun createPwaShortcut( + manifest: WebAppManifest, + profileUuid: String, + contextId: String?, + ): Boolean = withContext(Dispatchers.Main) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + logger.warn("Pinned shortcuts require Android O or later") + return@withContext false + } + + val shortcutManager = context.getSystemService() + ?: run { + logger.error("ShortcutManager not available") + return@withContext false + } + + if (!shortcutManager.isRequestPinShortcutSupported) { + logger.warn("Pinning shortcuts is not supported") + return@withContext false + } + + val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(manifest.startUrl) + putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid) + putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId) + } + + val (iconBitmap, isMaskable) = loadPwaIcon(manifest) + + val shortcutId = generateShortcutId(manifest.startUrl) + val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { + setShortLabel(manifest.shortName ?: manifest.name ?: "Web App") + setLongLabel(manifest.name ?: manifest.shortName ?: "Web App") + 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)) + } + } + }.build() + + val success = shortcutManager.requestPinShortcut(shortcut, null) + logger.debug("PWA shortcut creation result: $success") + success + } catch (e: Exception) { + logger.error("Failed to create PWA shortcut", e) + false + } + } + + /** + * Generates a collision-resistant shortcut ID from a URL using SHA-256. + */ + private fun generateShortcutId(url: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(url.toByteArray()) + val hex = hash.take(16).joinToString("") { "%02x".format(it) } + return "pwa_$hex" + } + + /** + * Loads the PWA icon from the manifest using BrowserIcons. + * Returns a pair of (bitmap, isMaskable) to determine proper icon format. + */ + private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair = 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() + + 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 iconResult = components.core.icons.loadIcon(iconRequest).await() + Pair(iconResult?.bitmap, isMaskable) + } else { + Pair(null, false) + } + } catch (e: Exception) { + logger.error("Failed to load PWA icon", e) + Pair(null, false) + } + } + + override fun getInstalledWebApps(callback: (Result>) -> Unit) { + logger.debug("getInstalledWebApps called") + 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() + } + logger.debug("Found ${pwaManifests.size} installed web apps") + callback(Result.success(pwaManifests)) + } catch (e: Exception) { + logger.error("Failed to get installed web apps", e) + callback(Result.failure(e)) + } + } + } + + private fun storeProfileMapping(startUrl: String, profileUuid: String) { + context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE) + .edit() + .putString(startUrl, profileUuid) + .apply() + } + + private fun getProfileMapping(startUrl: String): String? { + return context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE) + .getString(startUrl, null) + } + + private fun getCurrentProfileUuid(): String? { + return try { + val startupProfileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE) + if (startupProfileFile.exists()) { + startupProfileFile.readText().trim().ifEmpty { null } + } else { + null + } + } catch (e: Exception) { + logger.error("Failed to read current profile UUID", e) + null + } + } + + private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest { + return PwaManifest( + startUrl = startUrl, + currentUrl = currentUrl, + name = name, + shortName = shortName, + display = display?.name?.lowercase(), + themeColor = themeColor?.let { String.format("#%06X", 0xFFFFFF and it) }, + backgroundColor = backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) }, + scope = scope, + description = description, + icons = icons.map { icon -> + PwaIcon( + src = icon.src, + sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" }, + type = icon.type, + ) + }, + dir = dir?.name?.lowercase(), + lang = lang, + orientation = orientation?.name?.lowercase(), + relatedApplications = relatedApplications.map { app -> + ExternalApplicationResource( + platform = app.platform, + url = app.url, + id = app.id, + minVersion = app.minVersion, + ) + }, + preferRelatedApplications = preferRelatedApplications, + shareTarget = shareTarget?.let { target -> + ShareTarget( + action = target.action, + method = target.method?.name, + encType = target.encType?.type, + params = target.params?.let { params -> + ShareTargetParams( + title = params.title, + text = params.text, + url = params.url, + files = params.files.map { file -> + ShareTargetFiles( + name = file.name, + accept = file.accept, + ) + }, + ) + }, + ) + }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt index df84c9de..e80f3cdc 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSessionApiImpl.kt @@ -254,7 +254,7 @@ class GeckoSessionApiImpl : GeckoSessionApi { return } - components.engineView?.captureThumbnail { bitmap -> + components.mainBrowserEngineView?.captureThumbnail { bitmap -> try { if (bitmap != null) { components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap)) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt index 1ee8db97..02ee434e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt @@ -14,8 +14,12 @@ import mozilla.components.support.base.log.logger.Logger * Implementation of GeckoViewportApi that controls GeckoView's viewport behavior * for dynamic toolbar and keyboard handling. * - * This allows Flutter to control how GeckoView adjusts its internal viewport - * without resizing the platform view itself, avoiding visual flickering. + * Toolbar height and vertical clipping target the main browser's EngineView specifically, + * not the active/foreground EngineView. This prevents toolbar settings from leaking + * to PWA/Custom Tab EngineViews. + * + * If the main browser EngineView is not yet available when setDynamicToolbarMaxHeight + * is called, the value is stored and applied when the EngineView becomes available. */ class GeckoViewportApiImpl : GeckoViewportApi { companion object { @@ -28,66 +32,57 @@ class GeckoViewportApiImpl : GeckoViewportApi { requireNotNull(GlobalComponents.components) { "Components not initialized" } } - // Store the current dynamic toolbar max height - private var currentDynamicToolbarMaxHeight: Int = 0 + private var pendingToolbarHeight: Int? = null /** * Sets the maximum height that dynamic toolbars (top + bottom) can occupy. * - * GeckoView will adjust its internal viewport calculations to account for - * this space. The website will receive proper viewport dimensions through - * standard web APIs (CSS viewport units, window.innerHeight). + * Targets the main browser EngineView specifically. If the main browser + * EngineView is not yet available, the height is stored and applied when + * it becomes available via [applyPendingToolbarHeight]. */ override fun setDynamicToolbarMaxHeight(heightPx: Long) { val height = heightPx.toInt() - currentDynamicToolbarMaxHeight = height - val engineView = components.engineView + val engineView = components.mainBrowserEngineView if (engineView == null) { - logger.warn("$TAG: setDynamicToolbarMaxHeight called but engineView is null") + logger.debug("$TAG: setDynamicToolbarMaxHeight($height) - mainBrowserEngineView not ready, storing as pending") + pendingToolbarHeight = height return } + pendingToolbarHeight = null logger.debug("$TAG: setDynamicToolbarMaxHeight($height)") engineView.setDynamicToolbarMaxHeight(height) } + /** + * Applies any pending toolbar height to the main browser EngineView. + * Called when mainBrowserEngineView becomes available. + */ + fun applyPendingToolbarHeight() { + val pending = pendingToolbarHeight ?: return + val engineView = components.mainBrowserEngineView ?: return + pendingToolbarHeight = null + logger.debug("$TAG: Applying pending toolbar height: $pending") + engineView.setDynamicToolbarMaxHeight(pending) + } + /** * Sets the vertical clipping offset for the GeckoView content. * - * Use this as the toolbar animates to clip content at the bottom. - * Negative values clip from the bottom (for bottom toolbar sliding up). - * Positive values clip from the top (for top toolbar sliding down). + * Targets the main browser EngineView specifically. */ override fun setVerticalClipping(clippingPx: Long) { val clipping = clippingPx.toInt() - val engineView = components.engineView + val engineView = components.mainBrowserEngineView if (engineView == null) { - logger.warn("$TAG: setVerticalClipping called but engineView is null") + logger.warn("$TAG: setVerticalClipping called but mainBrowserEngineView is null") return } logger.debug("$TAG: setVerticalClipping($clipping)") engineView.setVerticalClipping(clipping) } - - /** - * Applies any pending viewport settings that were set before engineView was available. - * - * Call this method after setting components.engineView to ensure that any - * setDynamicToolbarMaxHeight calls made during startup are properly applied. - */ - fun applyPendingSettings() { - val engineView = components.engineView - if (engineView == null) { - logger.warn("$TAG: applyPendingSettings called but engineView is still null") - return - } - - if (currentDynamicToolbarMaxHeight > 0) { - logger.debug("$TAG: Applying pending dynamicToolbarMaxHeight: $currentDynamicToolbarMaxHeight") - engineView.setDynamicToolbarMaxHeight(currentDynamicToolbarMaxHeight) - } - } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index cc2f8f8f..291efc56 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -47,6 +47,8 @@ import mozilla.components.feature.addons.amo.AMOAddonsProvider import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker import mozilla.components.feature.addons.update.DefaultAddonUpdater import mozilla.components.feature.customtabs.store.CustomTabsServiceStore +import mozilla.components.feature.pwa.ManifestStorage +import mozilla.components.feature.pwa.WebAppShortcutManager import mozilla.components.feature.downloads.DownloadMiddleware import mozilla.components.feature.media.MediaSessionFeature import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware @@ -230,6 +232,14 @@ class Core( */ val customTabsStore by lazy { CustomTabsServiceStore() } + // Must use the base application context (not ProfileContext) so the database + // matches what WebAppLauncherActivity (from the library) uses when loading manifests. + val webAppManifestStorage by lazy { ManifestStorage(context.applicationContext) } + + val webAppShortcutManager by lazy { + WebAppShortcutManager(context, client, webAppManifestStorage) + } + /** * The storage component for persisting browser tab sessions. */ diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt index af324799..dc6b710a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt @@ -10,13 +10,18 @@ import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes +import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState -import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon +import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest import eu.weblibre.flutter_mozilla_components.pigeons.HistoryItem import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles +import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -25,10 +30,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.state.BrowserState import mozilla.components.feature.addons.logger import mozilla.components.lib.state.Store import mozilla.components.lib.state.ext.flowScoped +import kotlinx.coroutines.flow.distinctUntilChangedBy import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged @@ -53,12 +60,27 @@ class Events( } stateFlow.flowScoped { flow -> + var previousTabs = emptySet() flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } } .distinctUntilChanged() // Make sure this is sent after tabadded action .debounce { 25 } .collect { tabs -> - flutterEvents.onTabListChange(System.currentTimeMillis(), tabs) { _ -> } + val currentTabs = tabs.toSet() + if (previousTabs.isNotEmpty()) { + val removedTabs = previousTabs - currentTabs + if (removedTabs.isNotEmpty()) { + removedTabs.forEach { tabId -> + flutterEvents.onManifestUpdate( + EventSequence.next(), + tabId, + null + ) { _ -> } + } + } + } + previousTabs = currentTabs + flutterEvents.onTabListChange(EventSequence.next(), tabs) { _ -> } } } @@ -187,5 +209,89 @@ class Events( ) { _ -> } } } + + // PWA manifest availability events - following Fenix MenuPresenter pattern + stateFlow.flowScoped { flow -> + flow.mapNotNull { state -> state.selectedTab } + .ifAnyChanged { tab -> + arrayOf( + tab.content.loading, + tab.content.canGoBack, + tab.content.canGoForward, + tab.content.webAppManifest, + ) + } + .collect { tab -> + val manifest = tab.content.webAppManifest + val currentUrl = tab.content.url + + // If manifest is null, clear PWA state for this tab + if (manifest == null) { + flutterEvents.onManifestUpdate( + EventSequence.next(), + tab.id, + null + ) { _ -> } + return@collect + } + + val pwaManifest = PwaManifest( + startUrl = manifest.startUrl, + currentUrl = currentUrl, + name = manifest.name, + shortName = manifest.shortName, + display = manifest.display?.name?.lowercase(), + themeColor = manifest.themeColor?.let { String.format("#%06X", 0xFFFFFF and it) }, + backgroundColor = manifest.backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) }, + scope = manifest.scope, + description = manifest.description, + icons = manifest.icons.map { icon -> + PwaIcon( + src = icon.src, + sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" }, + type = icon.type, + ) + }, + dir = manifest.dir?.name?.lowercase(), + lang = manifest.lang, + orientation = manifest.orientation?.name?.lowercase(), + relatedApplications = manifest.relatedApplications.map { app -> + ExternalApplicationResource( + platform = app.platform, + url = app.url, + id = app.id, + minVersion = app.minVersion, + ) + }, + preferRelatedApplications = manifest.preferRelatedApplications, + shareTarget = manifest.shareTarget?.let { target -> + ShareTarget( + action = target.action, + method = target.method?.name, + encType = target.encType?.type, + params = target.params?.let { params -> + ShareTargetParams( + title = params.title, + text = params.text, + url = params.url, + files = params.files.map { file -> + ShareTargetFiles( + name = file.name, + accept = file.accept, + ) + }, + ) + }, + ) + }, + ) + + flutterEvents.onManifestUpdate( + EventSequence.next(), + tab.id, + pwaManifest + ) { _ -> } + } + } } -} \ No newline at end of file +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt index 0a8a541e..3ffbe587 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt @@ -12,6 +12,8 @@ import mozilla.components.feature.downloads.DownloadsUseCases import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SettingsUseCases import mozilla.components.feature.session.TrackingProtectionUseCases +import mozilla.components.feature.pwa.WebAppShortcutManager +import mozilla.components.feature.pwa.WebAppUseCases import mozilla.components.feature.tabs.CustomTabsUseCases import mozilla.components.feature.tabs.TabsUseCases @@ -23,6 +25,7 @@ class UseCases( private val context: Context, private val engine: Engine, private val store: BrowserStore, + private val shortcutManager: WebAppShortcutManager? = null, ) { /** * Use cases that provide engine interactions for a given browser session. @@ -52,4 +55,8 @@ class UseCases( val appLinksUseCases by lazy { AppLinksUseCases(context) } val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) } + + val webAppUseCases by lazy { + WebAppUseCases(context, store, shortcutManager ?: throw IllegalStateException("WebAppShortcutManager not provided")) + } } 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 53265a40..6992343e 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 @@ -2972,6 +2972,286 @@ data class TrackingProtectionException ( override fun hashCode(): Int = toList().hashCode() } + +/** + * Represents an icon from a PWA manifest. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PwaIcon ( + val src: String, + val sizes: String? = null, + val type: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): PwaIcon { + val src = pigeonVar_list[0] as String + val sizes = pigeonVar_list[1] as String? + val type = pigeonVar_list[2] as String? + return PwaIcon(src, sizes, type) + } + } + fun toList(): List { + return listOf( + src, + sizes, + type, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PwaIcon) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents a file entry in share target params. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ShareTargetFiles ( + val name: String, + val accept: List +) + { + companion object { + fun fromList(pigeonVar_list: List): ShareTargetFiles { + val name = pigeonVar_list[0] as String + val accept = pigeonVar_list[1] as List + return ShareTargetFiles(name, accept) + } + } + fun toList(): List { + return listOf( + name, + accept, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ShareTargetFiles) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents share target params. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ShareTargetParams ( + val title: String? = null, + val text: String? = null, + val url: String? = null, + val files: List +) + { + companion object { + fun fromList(pigeonVar_list: List): ShareTargetParams { + val title = pigeonVar_list[0] as String? + val text = pigeonVar_list[1] as String? + val url = pigeonVar_list[2] as String? + val files = pigeonVar_list[3] as List + return ShareTargetParams(title, text, url, files) + } + } + fun toList(): List { + return listOf( + title, + text, + url, + files, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ShareTargetParams) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents a share target for PWA. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ShareTarget ( + val action: String, + val method: String? = null, + val encType: String? = null, + val params: ShareTargetParams? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ShareTarget { + val action = pigeonVar_list[0] as String + val method = pigeonVar_list[1] as String? + val encType = pigeonVar_list[2] as String? + val params = pigeonVar_list[3] as ShareTargetParams? + return ShareTarget(action, method, encType, params) + } + } + fun toList(): List { + return listOf( + action, + method, + encType, + params, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ShareTarget) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents an external application resource. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ExternalApplicationResource ( + val platform: String, + val url: String? = null, + val id: String? = null, + val minVersion: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ExternalApplicationResource { + val platform = pigeonVar_list[0] as String + val url = pigeonVar_list[1] as String? + val id = pigeonVar_list[2] as String? + val minVersion = pigeonVar_list[3] as String? + return ExternalApplicationResource(platform, url, id, minVersion) + } + } + fun toList(): List { + return listOf( + platform, + url, + id, + minVersion, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ExternalApplicationResource) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Represents a PWA web app manifest. + * + * Mirrors Mozilla Android Components' WebAppManifest structure. + * https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PwaManifest ( + val startUrl: String, + val name: String? = null, + val shortName: String? = null, + val display: String? = null, + val themeColor: String? = null, + val backgroundColor: String? = null, + val scope: String? = null, + val description: String? = null, + val icons: List, + val dir: String? = null, + val lang: String? = null, + val orientation: String? = null, + val relatedApplications: List, + val preferRelatedApplications: Boolean, + val shareTarget: ShareTarget? = null, + /** + * The URL of the page when the manifest was detected. + * Used for HTTPS/installability checks. + */ + val currentUrl: String +) + { + companion object { + fun fromList(pigeonVar_list: List): PwaManifest { + val startUrl = pigeonVar_list[0] as String + val name = pigeonVar_list[1] as String? + val shortName = pigeonVar_list[2] as String? + val display = pigeonVar_list[3] as String? + val themeColor = pigeonVar_list[4] as String? + val backgroundColor = pigeonVar_list[5] as String? + val scope = pigeonVar_list[6] as String? + val description = pigeonVar_list[7] as String? + val icons = pigeonVar_list[8] as List + val dir = pigeonVar_list[9] as String? + val lang = pigeonVar_list[10] as String? + val orientation = pigeonVar_list[11] as String? + val relatedApplications = pigeonVar_list[12] as List + 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) + } + } + fun toList(): List { + return listOf( + startUrl, + name, + shortName, + display, + themeColor, + backgroundColor, + scope, + description, + icons, + dir, + lang, + orientation, + relatedApplications, + preferRelatedApplications, + shareTarget, + currentUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PwaManifest) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} private open class GeckoPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -3400,6 +3680,36 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { TrackingProtectionException.fromList(it) } } + 214.toByte() -> { + return (readValue(buffer) as? List)?.let { + PwaIcon.fromList(it) + } + } + 215.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetFiles.fromList(it) + } + } + 216.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetParams.fromList(it) + } + } + 217.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTarget.fromList(it) + } + } + 218.toByte() -> { + return (readValue(buffer) as? List)?.let { + ExternalApplicationResource.fromList(it) + } + } + 219.toByte() -> { + return (readValue(buffer) as? List)?.let { + PwaManifest.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -3745,6 +4055,30 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(213) writeValue(stream, value.toList()) } + is PwaIcon -> { + stream.write(214) + writeValue(stream, value.toList()) + } + is ShareTargetFiles -> { + stream.write(215) + writeValue(stream, value.toList()) + } + is ShareTargetParams -> { + stream.write(216) + writeValue(stream, value.toList()) + } + is ShareTarget -> { + stream.write(217) + writeValue(stream, value.toList()) + } + is ExternalApplicationResource -> { + stream.write(218) + writeValue(stream, value.toList()) + } + is PwaManifest -> { + stream.write(219) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -5320,12 +5654,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val GeckoPigeonCodec() } } - fun onViewReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result) -> Unit) + fun onViewReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, stateArg)) { + channel.send(listOf(sequenceArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5337,12 +5671,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onEngineReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result) -> Unit) + fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, stateArg)) { + channel.send(listOf(sequenceArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5354,12 +5688,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onIconUpdate(timestampArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result) -> Unit) + fun onIconUpdate(sequenceArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, urlArg, bytesArg)) { + channel.send(listOf(sequenceArg, urlArg, bytesArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5371,12 +5705,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onTabAdded(timestampArg: Long, tabIdArg: String, callback: (Result) -> Unit) + fun onTabAdded(sequenceArg: Long, tabIdArg: String, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, tabIdArg)) { + channel.send(listOf(sequenceArg, tabIdArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5388,12 +5722,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onTabListChange(timestampArg: Long, tabIdsArg: List, callback: (Result) -> Unit) + fun onTabListChange(sequenceArg: Long, tabIdsArg: List, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, tabIdsArg)) { + channel.send(listOf(sequenceArg, tabIdsArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5405,12 +5739,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onSelectedTabChange(timestampArg: Long, idArg: String?, callback: (Result) -> Unit) + fun onSelectedTabChange(sequenceArg: Long, idArg: String?, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg)) { + channel.send(listOf(sequenceArg, idArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5422,12 +5756,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onTabContentStateChange(timestampArg: Long, stateArg: TabContentState, callback: (Result) -> Unit) + fun onTabContentStateChange(sequenceArg: Long, stateArg: TabContentState, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, stateArg)) { + channel.send(listOf(sequenceArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5439,12 +5773,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onHistoryStateChange(timestampArg: Long, idArg: String, stateArg: HistoryState, callback: (Result) -> Unit) + fun onHistoryStateChange(sequenceArg: Long, idArg: String, stateArg: HistoryState, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, stateArg)) { + channel.send(listOf(sequenceArg, idArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5456,12 +5790,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onReaderableStateChange(timestampArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result) -> Unit) + fun onReaderableStateChange(sequenceArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, stateArg)) { + channel.send(listOf(sequenceArg, idArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5473,12 +5807,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onSecurityInfoStateChange(timestampArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result) -> Unit) + fun onSecurityInfoStateChange(sequenceArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, stateArg)) { + channel.send(listOf(sequenceArg, idArg, stateArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5490,12 +5824,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onIconChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) + fun onIconChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, bytesArg)) { + channel.send(listOf(sequenceArg, idArg, bytesArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5507,12 +5841,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onThumbnailChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) + fun onThumbnailChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, bytesArg)) { + channel.send(listOf(sequenceArg, idArg, bytesArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5524,12 +5858,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onFindResults(timestampArg: Long, idArg: String, resultsArg: List, callback: (Result) -> Unit) + fun onFindResults(sequenceArg: Long, idArg: String, resultsArg: List, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, resultsArg)) { + channel.send(listOf(sequenceArg, idArg, resultsArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5541,12 +5875,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onLongPress(timestampArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result) -> Unit) + fun onLongPress(sequenceArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, idArg, hitResultArg)) { + channel.send(listOf(sequenceArg, idArg, hitResultArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5558,12 +5892,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onPreferenceChange(timestampArg: Long, valueArg: GeckoPref, callback: (Result) -> Unit) + fun onPreferenceChange(sequenceArg: Long, valueArg: GeckoPref, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onPreferenceChange$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, valueArg)) { + channel.send(listOf(sequenceArg, valueArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5575,12 +5909,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onContainerSiteAssignment(timestampArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result) -> Unit) + fun onContainerSiteAssignment(sequenceArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onContainerSiteAssignment$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, detailsArg)) { + channel.send(listOf(sequenceArg, detailsArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5592,12 +5926,29 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onMlProgress(timestampArg: Long, progressArg: MlProgressData, callback: (Result) -> Unit) + fun onMlProgress(sequenceArg: Long, progressArg: MlProgressData, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onMlProgress$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, progressArg)) { + channel.send(listOf(sequenceArg, progressArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onManifestUpdate(sequenceArg: Long, tabIdArg: String, manifestArg: PwaManifest?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onManifestUpdate$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, tabIdArg, manifestArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5695,12 +6046,12 @@ class ReaderViewController(private val binaryMessenger: BinaryMessenger, private GeckoPigeonCodec() } } - fun appearanceButtonVisibility(timestampArg: Long, visibleArg: Boolean, callback: (Result) -> Unit) + fun appearanceButtonVisibility(sequenceArg: Long, visibleArg: Boolean, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, visibleArg)) { + channel.send(listOf(sequenceArg, visibleArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5853,12 +6204,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val GeckoPigeonCodec() } } - fun onUpsertWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result) -> Unit) + fun onUpsertWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, extensionDataArg)) { + channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, extensionDataArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5870,12 +6221,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onRemoveWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result) -> Unit) + fun onRemoveWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg)) { + channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5887,12 +6238,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val } } } - fun onUpdateWebExtensionIcon(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result) -> Unit) + fun onUpdateWebExtensionIcon(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, iconArg)) { + channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, iconArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5969,12 +6320,12 @@ class GeckoSuggestionEvents(private val binaryMessenger: BinaryMessenger, privat GeckoPigeonCodec() } } - fun onSuggestionResult(timestampArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List, callback: (Result) -> Unit) + fun onSuggestionResult(sequenceArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, suggestionTypeArg, suggestionsArg)) { + channel.send(listOf(sequenceArg, suggestionTypeArg, suggestionsArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -5995,12 +6346,12 @@ class GeckoTabContentEvents(private val binaryMessenger: BinaryMessenger, privat GeckoPigeonCodec() } } - fun onContentUpdate(timestampArg: Long, contentArg: TabContent, callback: (Result) -> Unit) + fun onContentUpdate(sequenceArg: Long, contentArg: TabContent, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, contentArg)) { + channel.send(listOf(sequenceArg, contentArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -6384,12 +6735,12 @@ class BrowserExtensionEvents(private val binaryMessenger: BinaryMessenger, priva GeckoPigeonCodec() } } - fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result) -> Unit) + fun onFeedRequested(sequenceArg: Long, urlArg: String, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, urlArg)) { + channel.send(listOf(sequenceArg, urlArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -6546,17 +6897,17 @@ class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private * This is detected natively using WindowInsets API and provides * accurate keyboard height information. * - * [timestamp] Event timestamp for ordering. + * [sequence] Event sequence number for ordering. * [heightPx] Keyboard height in pixels (0 when hidden). * [isVisible] Whether the keyboard is currently visible. * [isAnimating] Whether the keyboard is currently animating. */ - fun onKeyboardVisibilityChanged(timestampArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result) -> Unit) + fun onKeyboardVisibilityChanged(sequenceArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onKeyboardVisibilityChanged$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(timestampArg, heightPxArg, isVisibleArg, isAnimatingArg)) { + channel.send(listOf(sequenceArg, heightPxArg, isVisibleArg, isAnimatingArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -7237,3 +7588,80 @@ interface GeckoAppLinksApi { } } } +/** + * API for PWA (Progressive Web App) installation and management. + * + * Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage + * to provide PWA install and query functionality to Flutter. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface GeckoPwaApi { + /** + * Installs the current page as a PWA (adds to home screen). + * + * Creates an Android shortcut with profile and container metadata embedded + * in the intent extras. This ensures the PWA opens with the same profile + * and container context that was active during installation. + * + * 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). + * Returns true if installation was successful. + */ + fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result) -> Unit) + /** Returns a list of all installed PWA manifests. */ + fun getInstalledWebApps(callback: (Result>) -> Unit) + + companion object { + /** The codec used by GeckoPwaApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoPwaApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoPwaApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tabIdArg = args[0] as String? + val profileUuidArg = args[1] as String + val contextIdArg = args[2] as String? + api.installWebApp(tabIdArg, profileUuidArg, contextIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.getInstalledWebApps$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getInstalledWebApps{ result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ui/LoadingScreenManager.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ui/LoadingScreenManager.kt new file mode 100644 index 00000000..634b7cba --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ui/LoadingScreenManager.kt @@ -0,0 +1,297 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.ui + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.ColorUtils +import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.R +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.support.base.log.logger.Logger +import kotlinx.coroutines.* + +/** + * Manager for displaying branded loading screens during PWA and Custom Tab initialization. + * Handles loading of icons, theming, and animations. + */ +class LoadingScreenManager private constructor( + private val activity: Activity, + private val container: FrameLayout +) { + private val logger = Logger("LoadingScreenManager") + private var currentLoadingView: View? = null + private var pulseAnimator: ObjectAnimator? = null + + companion object { + /** + * Creates a LoadingScreenManager for the given activity. + * The container should be the root view where loading screens will be added. + */ + fun forActivity(activity: Activity, container: FrameLayout): LoadingScreenManager { + return LoadingScreenManager(activity, container) + } + + /** + * Detects if an intent is for a PWA based on the extras. + */ + fun isPwaIntent(intent: Intent?): Boolean { + return intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true + } + + /** + * Extracts URL from an intent. + */ + fun extractUrl(intent: Intent?): String? { + return intent?.dataString + } + } + + /** + * Shows the appropriate loading screen immediately based on intent analysis. + * This can be called before components are initialized. + * + * @param intent The intent to analyze for type detection + */ + fun showLoadingForIntent(intent: Intent) { + when { + isPwaIntent(intent) -> showPwaPlaceholder(intent) + else -> showCustomTabPlaceholder(intent) + } + } + + /** + * Shows a PWA placeholder loading screen immediately (before components are ready). + * Uses URL to extract domain as temporary app name. + */ + private fun showPwaPlaceholder(intent: Intent) { + cleanup() + + val view = LayoutInflater.from(activity).inflate( + R.layout.pwa_loading_screen, + container, + false + ) + + // Extract URL and use domain as temporary name + val url = intent.dataString + val domain = url?.let { extractDomain(it) } ?: "Web App" + + // Set temporary app name (will be replaced with actual name once manifest loads) + val nameView = view.findViewById(R.id.pwa_name) + nameView.text = domain + + // Show WebLibre logo as placeholder (already set in XML layout) + val iconView = view.findViewById(R.id.pwa_icon) + iconView.alpha = 0.5f + + // Start pulsing animation + startPulseAnimation(view) + + container.addView(view) + currentLoadingView = view + } + + /** + * Shows a Custom Tab placeholder loading screen immediately (before components are ready). + */ + private fun showCustomTabPlaceholder(intent: Intent) { + cleanup() + + val view = LayoutInflater.from(activity).inflate( + R.layout.custom_tab_loading_screen, + container, + false + ) + + // Extract and display domain + val url = intent.dataString ?: return + val domainView = view.findViewById(R.id.custom_tab_domain) + domainView.text = extractDomain(url) + + container.addView(view) + currentLoadingView = view + } + + /** + * Enhances the current loading screen with actual PWA data once components are ready. + * This updates the placeholder with real manifest data. + */ + fun enhancePwaLoading( + manifest: WebAppManifest, + browserIcons: BrowserIcons, + coroutineScope: CoroutineScope + ) { + currentLoadingView?.let { view -> + // Apply theme colors if available + manifest.themeColor?.let { colorInt -> + view.setBackgroundColor(colorInt) + + val isDark = ColorUtils.calculateLuminance(colorInt) < 0.5 + val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK + val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3) + + view.findViewById(R.id.pwa_name)?.setTextColor(primaryTextColor) + view.findViewById(R.id.pwa_status)?.setTextColor(secondaryTextColor) + } + + // Update app name + val nameView = view.findViewById(R.id.pwa_name) + val appName = manifest.shortName ?: manifest.name + if (appName != null && nameView.text != appName) { + nameView.text = appName + } + + // Load the PWA icon asynchronously and update + coroutineScope.launch(Dispatchers.IO) { + loadPwaIcon(manifest, browserIcons)?.let { bitmap -> + withContext(Dispatchers.Main) { + val iconView = view.findViewById(R.id.pwa_icon) + iconView.alpha = 1.0f + iconView.setImageBitmap(bitmap) + } + } + } + } + } + + /** + * Enhances the Custom Tab loading screen with favicon once components are ready. + */ + fun enhanceCustomTabLoading( + url: String, + browserIcons: BrowserIcons, + coroutineScope: CoroutineScope + ) { + currentLoadingView?.let { view -> + coroutineScope.launch(Dispatchers.IO) { + try { + val iconRequest = IconRequest( + url = url, + size = IconRequest.Size.DEFAULT + ) + val iconResult = browserIcons.loadIcon(iconRequest).await() + iconResult?.bitmap?.let { bitmap -> + withContext(Dispatchers.Main) { + val iconView = view.findViewById(R.id.custom_tab_icon) + iconView.setImageBitmap(bitmap) + iconView.alpha = 1.0f + } + } + } catch (e: Exception) { + logger.debug("Failed to load favicon for $url") + } + } + } + } + + /** + * Hides the loading screen with an optional fade-out animation. + */ + fun hideLoading(animate: Boolean = true) { + currentLoadingView?.let { view -> + if (animate) { + view.animate() + .alpha(0f) + .setDuration(200) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + cleanup() + } + }) + .start() + } else { + cleanup() + } + } + } + + /** + * Cleans up the loading view and animations. + */ + fun cleanup() { + pulseAnimator?.cancel() + pulseAnimator = null + + currentLoadingView?.let { view -> + container.removeView(view) + } + currentLoadingView = null + } + + private fun startPulseAnimation(view: View) { + val pulseView = view.findViewById(R.id.pwa_icon_pulse) + ?: return + + pulseView.alpha = 0.0f + pulseAnimator = ObjectAnimator.ofFloat(pulseView, "alpha", 0.0f, 0.3f, 0.0f).apply { + duration = 1500 + repeatCount = ObjectAnimator.INFINITE + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + + private suspend fun loadPwaIcon( + manifest: WebAppManifest, + browserIcons: BrowserIcons + ): Bitmap? { + return 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() + + iconResource?.let { icon -> + val iconRequest = IconRequest( + url = manifest.startUrl, + size = IconRequest.Size.LAUNCHER, + resources = listOf( + IconRequest.Resource( + url = icon.src, + type = IconRequest.Resource.Type.MANIFEST_ICON, + sizes = icon.sizes?.map { size -> + mozilla.components.concept.engine.manifest.Size(size.width, size.height) + } ?: emptyList(), + mimeType = icon.type, + maskable = icon.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) + ) + ) + ) + + val result = browserIcons.loadIcon(iconRequest).await() + result?.bitmap + } + } catch (e: Exception) { + logger.error("Failed to load PWA icon", e) + null + } + } + + private fun extractDomain(url: String): String { + return try { + val uri = android.net.Uri.parse(url) + uri.host ?: url + } catch (e: Exception) { + url + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbar.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbar.kt new file mode 100644 index 00000000..a150d60b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbar.kt @@ -0,0 +1,190 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import com.google.android.material.card.MaterialCardView +import com.google.android.material.color.MaterialColors +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.IIcon +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp +import eu.weblibre.flutter_mozilla_components.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.state.CustomTabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import mozilla.components.support.ktx.util.URLStringUtils + +/** + * Custom tab toolbar with pill-shaped Material 3 design. + * Uses MDI (Pictogrammers) icons via the Iconics library. + */ +class CustomTabToolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val toolbarCard: MaterialCardView + private val closeButton: ImageButton + private val securityIcon: ImageView + private val urlText: TextView + private val shareButton: ImageButton + private val openInBrowserButton: ImageButton + private val menuButton: ImageButton + + private var sessionId: String? = null + private var store: BrowserStore? = null + private var urlScope: CoroutineScope? = null + private var securityScope: CoroutineScope? = null + + var onCloseListener: (() -> Unit)? = null + var onShareListener: (() -> Unit)? = null + var onOpenInBrowserListener: (() -> Unit)? = null + var onMenuListener: (() -> Unit)? = null + + init { + inflate(context, R.layout.custom_tab_toolbar, this) + + toolbarCard = findViewById(R.id.toolbarCard) + closeButton = findViewById(R.id.closeButton) + securityIcon = findViewById(R.id.securityIcon) + urlText = findViewById(R.id.urlText) + shareButton = findViewById(R.id.shareButton) + openInBrowserButton = findViewById(R.id.openInBrowserButton) + menuButton = findViewById(R.id.menuButton) + + closeButton.setOnClickListener { onCloseListener?.invoke() } + shareButton.setOnClickListener { onShareListener?.invoke() } + openInBrowserButton.setOnClickListener { onOpenInBrowserListener?.invoke() } + menuButton.setOnClickListener { onMenuListener?.invoke() } + + applyMaterial3Colors() + applyIcons() + } + + fun bind(sessionId: String, store: BrowserStore, toolbarColor: Int? = null) { + this.sessionId = sessionId + this.store = store + + toolbarColor?.let { applyCustomColors(it) } + + store.state.findCustomTab(sessionId)?.let { tab -> + updateUrl(tab) + updateSecurityIcon(tab) + } + + observeUrlChanges() + observeSecurityChanges() + } + + fun unbind() { + urlScope?.cancel() + urlScope = null + securityScope?.cancel() + securityScope = null + } + + fun getMenuButton(): View = menuButton + + fun getUrl(): String? = urlText.text?.toString() + + private fun applyMaterial3Colors() { + val surfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface) + val onSurfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface) + toolbarCard.setCardBackgroundColor(surfaceColor) + urlText.setTextColor(onSurfaceColor) + } + + private fun applyIcons() { + val iconColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant) + + closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, iconColor)) + shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, iconColor)) + openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, iconColor)) + menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, iconColor)) + } + + private fun applyCustomColors(color: Int) { + toolbarCard.setCardBackgroundColor(color) + val textColor = if (isDarkColor(color)) { + android.graphics.Color.WHITE + } else { + android.graphics.Color.BLACK + } + urlText.setTextColor(textColor) + + closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, textColor)) + shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, textColor)) + openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, textColor)) + menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, textColor)) + } + + private fun isDarkColor(color: Int): Boolean { + val darkness = 1 - (0.299 * android.graphics.Color.red(color) + + 0.587 * android.graphics.Color.green(color) + + 0.114 * android.graphics.Color.blue(color)) / 255 + return darkness >= 0.5 + } + + private fun observeUrlChanges() { + val sessionId = this.sessionId ?: return + val store = this.store ?: return + + urlScope = store.flowScoped { flow -> + flow + .mapNotNull { state -> state.findCustomTab(sessionId) } + .ifAnyChanged { tab -> arrayOf(tab.content.url) } + .collect { tab -> updateUrl(tab) } + } + } + + private fun observeSecurityChanges() { + val sessionId = this.sessionId ?: return + val store = this.store ?: return + + securityScope = store.flowScoped { flow -> + flow + .mapNotNull { state -> state.findCustomTab(sessionId) } + .ifAnyChanged { tab -> arrayOf(tab.content.securityInfo.isSecure) } + .collect { tab -> updateSecurityIcon(tab) } + } + } + + private fun updateUrl(tab: CustomTabSessionState) { + urlText.text = URLStringUtils.toDisplayUrl(tab.content.url) + } + + private fun updateSecurityIcon(tab: CustomTabSessionState) { + if (tab.content.securityInfo.isSecure) { + val color = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant) + securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon2.cmd_lock, 16, color)) + } else { + val color = MaterialColors.getColor(this, android.R.attr.colorError) + securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_web, 16, color)) + } + } + + private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable { + return IconicsDrawable(context, icon).apply { + this.sizeDp = sizeDp + this.colorInt = color + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbarFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbarFeature.kt new file mode 100644 index 00000000..323e9651 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/CustomTabToolbarFeature.kt @@ -0,0 +1,51 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.widget + +import android.view.Window +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.feature.UserInteractionHandler + +/** + * Feature that connects [CustomTabToolbar] to browser state. + * Handles lifecycle, state observation, and window color updates. + */ +class CustomTabToolbarFeature( + private val store: BrowserStore, + private val toolbar: CustomTabToolbar, + private val sessionId: String, + private val window: Window +) : LifecycleAwareFeature, DefaultLifecycleObserver, UserInteractionHandler { + + override fun start() { + val tab = store.state.findCustomTab(sessionId) ?: return + + val toolbarColor = tab.config.colorSchemes?.defaultColorSchemeParams?.toolbarColor + + toolbar.bind(sessionId, store, toolbarColor) + + toolbarColor?.let { color -> + window.statusBarColor = color + window.navigationBarColor = color + } + } + + override fun stop() { + toolbar.unbind() + } + + override fun onDestroy(owner: LifecycleOwner) { + stop() + super.onDestroy(owner) + } + + override fun onBackPressed(): Boolean = false +} diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/custom_tab_menu_bg.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/custom_tab_menu_bg.xml new file mode 100644 index 00000000..c4104c63 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/custom_tab_menu_bg.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/pulse_ripple.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/pulse_ripple.xml new file mode 100644 index 00000000..24dc7baf --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/pulse_ripple.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_external_app_browser.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_external_app_browser.xml new file mode 100644 index 00000000..1239317b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_external_app_browser.xml @@ -0,0 +1,10 @@ + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_loading_screen.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_loading_screen.xml new file mode 100644 index 00000000..9a2933d3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_loading_screen.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_menu.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_menu.xml new file mode 100644 index 00000000..1452ff4c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_menu.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_toolbar.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_toolbar.xml new file mode 100644 index 00000000..6491a349 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/custom_tab_toolbar.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml index 16917cdb..7390ce32 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml @@ -3,12 +3,22 @@ - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + + + + + + - \ No newline at end of file + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/pwa_loading_screen.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/pwa_loading_screen.xml new file mode 100644 index 00000000..2b9b56bd --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/pwa_loading_screen.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml b/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml index 8e170672..a6f520b6 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml @@ -16,4 +16,19 @@ Failed to uninstall %1$s Failed to query extensions! + + Share + Desktop site + Open in browser + Back + Forward + Refresh + Close + Menu + Security + + + Opening… + App icon + Website icon \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/values/styles.xml b/packages/flutter_mozilla_components/android/src/main/res/values/styles.xml index ed88a36e..8c6a1337 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/values/styles.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/values/styles.xml @@ -7,4 +7,10 @@ true @android:style/Animation + + + \ No newline at end of file diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index b2e0821b..38e95b5c 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -58,6 +58,7 @@ export 'src/pigeons/gecko.g.dart' GeckoFetchResponse, GeckoPref, GeckoPublicSuffixListApi, + GeckoPwaApi, GeckoSitePermissionsApi, GeckoSuggestion, GeckoSuggestionType, @@ -75,6 +76,8 @@ export 'src/pigeons/gecko.g.dart' MlProgressStatus, MlProgressType, PhoneHitResult, + PwaIcon, + PwaManifest, QueryParameterStripping, Resource, ResourceSize, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart index 7cdcd5ff..d4fa84ee 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart @@ -202,8 +202,12 @@ class GeckoEventService extends GeckoStateEvents { _mlProgressSubject.addWhenMoreRecent(sequence, null, progress); } - void onMlProgress(int timestamp, MlProgressData progress) { - _mlProgressSubject.addWhenMoreRecent(timestamp, null, progress); + @override + void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest) { + _manifestUpdateSubject.addWhenMoreRecent(sequence, tabId, ( + tabId: tabId, + manifest: manifest, + )); } GeckoEventService.setUp({ diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index fd35f463..05688a34 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1422,7 +1422,7 @@ abstract class GeckoStateEvents { void onMlProgress(int sequence, MlProgressData progress); - void onMlProgress(int timestamp, MlProgressData progress); + void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest); } @FlutterApi() @@ -2017,3 +2017,138 @@ abstract class GeckoAppLinksApi { @async bool openAppLink(String url); } + +// ============================================================================= +// PWA API +// ============================================================================= + +/// Represents an icon from a PWA manifest. +class PwaIcon { + final String src; + final String? sizes; + final String? type; + + const PwaIcon({required this.src, this.sizes, this.type}); +} + +/// Represents a file entry in share target params. +class ShareTargetFiles { + final String name; + final List accept; + + const ShareTargetFiles({required this.name, required this.accept}); +} + +/// Represents share target params. +class ShareTargetParams { + final String? title; + final String? text; + final String? url; + final List files; + + const ShareTargetParams({ + this.title, + this.text, + this.url, + this.files = const [], + }); +} + +/// Represents a share target for PWA. +class ShareTarget { + final String action; + final String? method; + final String? encType; + final ShareTargetParams? params; + + const ShareTarget({ + required this.action, + this.method, + this.encType, + this.params, + }); +} + +/// Represents an external application resource. +class ExternalApplicationResource { + final String platform; + final String? url; + final String? id; + final String? minVersion; + + const ExternalApplicationResource({ + required this.platform, + this.url, + this.id, + this.minVersion, + }); +} + +/// Represents a PWA web app manifest. +/// +/// Mirrors Mozilla Android Components' WebAppManifest structure. +/// https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html +class PwaManifest { + final String startUrl; + final String? name; + final String? shortName; + final String? display; + final String? themeColor; + final String? backgroundColor; + final String? scope; + final String? description; + final List icons; + final String? dir; + final String? lang; + final String? orientation; + final List relatedApplications; + final bool preferRelatedApplications; + final ShareTarget? shareTarget; + + /// The URL of the page when the manifest was detected. + /// Used for HTTPS/installability checks. + final String currentUrl; + + const PwaManifest({ + required this.startUrl, + required this.currentUrl, + this.name, + this.shortName, + this.display, + this.themeColor, + this.backgroundColor, + this.scope, + this.description, + this.icons = const [], + this.dir, + this.lang, + this.orientation, + this.relatedApplications = const [], + this.preferRelatedApplications = false, + this.shareTarget, + }); +} + +/// API for PWA (Progressive Web App) installation and management. +/// +/// Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage +/// to provide PWA install and query functionality to Flutter. +@HostApi() +abstract class GeckoPwaApi { + /// Installs the current page as a PWA (adds to home screen). + /// + /// Creates an Android shortcut with profile and container metadata embedded + /// in the intent extras. This ensures the PWA opens with the same profile + /// and container context that was active during installation. + /// + /// 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). + /// Returns true if installation was successful. + @async + bool installWebApp(String? tabId, String profileUuid, String? contextId); + + /// Returns a list of all installed PWA manifests. + @async + List getInstalledWebApps(); +}