From 36d85f93731bb111de085e4f1077f9cee8f26ca9 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 19 Jul 2026 12:12:33 +0200 Subject: [PATCH] push feature stable --- .../android/app/src/main/AndroidManifest.xml | 3 +- .../kotlin/eu/weblibre/gecko/MyApplication.kt | 3 +- apps/weblibre/lib/core/routing/routes.dart | 1 + apps/weblibre/lib/core/routing/routes.g.dart | 26 + .../lib/core/routing/routes.settings.dart | 11 + .../providers/engine_suggestions.g.dart | 2 +- .../screens/experimental_settings.dart | 48 +- .../presentation/screens/settings.dart | 9 + .../dialogs/switch_profile_dialog.dart | 2 +- .../utils/profile_switch_handler.dart | 13 +- .../user/domain/repositories/profile.dart | 20 +- .../user/domain/repositories/profile.g.dart | 2 +- .../features/web_push/domain/providers.dart | 216 +++ .../features/web_push/domain/providers.g.dart | 341 +++++ .../screens/web_push_settings.dart | 435 ++++++ .../web_push/domain/providers_test.dart | 182 +++ .../screens/web_push_settings_test.dart | 148 ++ .../android/build.gradle | 1 + .../ActiveProfile.kt | 50 +- .../flutter_mozilla_components/Components.kt | 8 +- .../FlutterMozillaComponentsPlugin.kt | 6 + .../GlobalComponents.kt | 68 + .../ProfileContext.kt | 3 + .../api/GeckoBrowserApiImpl.kt | 42 +- .../api/GeckoPushApiImpl.kt | 82 + .../components/Core.kt | 14 +- .../components/Push.kt | 53 - .../pigeons/Gecko.g.kt | 1035 ++++++++++--- .../flutter_mozilla_components/push/Push.kt | 473 ++++++ .../push/PushMessageScheduler.kt | 96 ++ .../push/PushMessageStore.kt | 191 +++ .../push/PushMessageWorker.kt | 114 ++ .../push/PushPigeonMappers.kt | 27 + .../push/PushProfileState.kt | 72 + .../push/UnifiedPushReceiver.kt | 173 +++ .../push/WebNotificationDrainCoordinator.kt | 95 ++ .../push/WebPushEngineIntegration.kt | 22 + .../receivers/UnifiedPushReceiver.kt | 75 - .../FlutterMozillaContextPluginTest.kt | 26 +- .../ActiveProfileTest.kt | 56 + .../push/ProfileSwitchTimeoutTest.kt | 52 + .../push/PushMessageStoreTest.kt | 132 ++ .../push/UnifiedPushReceiverTest.kt | 79 + .../lib/flutter_mozilla_components.dart | 5 + .../src/domain/services/gecko_browser.dart | 4 - .../lib/src/domain/services/gecko_push.dart | 115 ++ .../lib/src/pigeons/gecko.g.dart | 1315 +++++++++++++---- .../pigeons/gecko.dart | 104 +- .../test/gecko_push_test.dart | 96 ++ 49 files changed, 5473 insertions(+), 673 deletions(-) create mode 100644 apps/weblibre/lib/features/web_push/domain/providers.dart create mode 100644 apps/weblibre/lib/features/web_push/domain/providers.g.dart create mode 100644 apps/weblibre/lib/features/web_push/presentation/screens/web_push_settings.dart create mode 100644 apps/weblibre/test/features/web_push/domain/providers_test.dart create mode 100644 apps/weblibre/test/features/web_push/presentation/screens/web_push_settings_test.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPushApiImpl.kt delete mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Push.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/Push.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageScheduler.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStore.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageWorker.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushPigeonMappers.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushProfileState.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiver.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebNotificationDrainCoordinator.kt delete mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/receivers/UnifiedPushReceiver.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfileTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/ProfileSwitchTimeoutTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStoreTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiverTest.kt create mode 100644 packages/flutter_mozilla_components/lib/src/domain/services/gecko_push.dart create mode 100644 packages/flutter_mozilla_components/test/gecko_push_test.dart diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml index 1ab1bea6..bbf44ffb 100644 --- a/apps/weblibre/android/app/src/main/AndroidManifest.xml +++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml @@ -75,7 +75,7 @@ tools:node="remove" /> @@ -83,6 +83,7 @@ + diff --git a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt index 88c7632e..295764e4 100644 --- a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt +++ b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt @@ -24,6 +24,7 @@ import android.content.SharedPreferences import eu.weblibre.flutter_mozilla_components.ActiveProfile import eu.weblibre.flutter_mozilla_components.MegazordSetup import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature +import eu.weblibre.flutter_mozilla_components.push.PushMessageScheduler class MyApplication : Application() { override fun onCreate() { @@ -33,7 +34,7 @@ class MyApplication : Application() { // Resolve active profile EARLY so cold-start WorkManager workers // get profile-prefixed SharedPreferences - ActiveProfile.resolveFromDisk(this) + ActiveProfile.resolveFromDisk(this)?.let(PushMessageScheduler::recoverLater) // Rehydrate the sandbox capture registry from the on-disk JSON mirror // before Gecko has a chance to start restoring tabs. Each entry gets diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 03ebe30d..71c454ad 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -111,6 +111,7 @@ import 'package:weblibre/features/web_feed/presentation/screens/feed_article_lis import 'package:weblibre/features/web_feed/presentation/screens/feed_edit.dart'; import 'package:weblibre/features/web_feed/presentation/screens/feed_list.dart'; import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'; +import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart'; part 'routes.bangs.dart'; part 'routes.bookmarks.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index b59bb5e3..05d1b8c6 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -1592,6 +1592,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'ExperimentalSettingsRoute', factory: $ExperimentalSettingsRoute._fromState, ), + GoRouteData.$route( + path: 'push', + name: 'WebPushSettingsRoute', + factory: $WebPushSettingsRoute._fromState, + ), GoRouteData.$route( path: 'bang', name: 'BangSettingsRoute', @@ -1949,6 +1954,27 @@ mixin $ExperimentalSettingsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $WebPushSettingsRoute on GoRouteData { + static WebPushSettingsRoute _fromState(GoRouterState state) => + WebPushSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/push'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $BangSettingsRoute on GoRouteData { static BangSettingsRoute _fromState(GoRouterState state) => BangSettingsRoute(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index a9409225..4d926c46 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -63,6 +63,10 @@ part of 'routes.dart'; name: 'ExperimentalSettingsRoute', path: 'experimental', ), + TypedGoRoute( + name: 'WebPushSettingsRoute', + path: 'push', + ), TypedGoRoute(name: 'BangSettingsRoute', path: 'bang'), TypedGoRoute( name: 'WebEngineHardeningRoute', @@ -232,6 +236,13 @@ class ExperimentalSettingsRoute extends GoRouteData } } +class WebPushSettingsRoute extends GoRouteData with $WebPushSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const WebPushSettingsScreen(); + } +} + class BangSettingsRoute extends GoRouteData with $BangSettingsRoute { @override Widget build(BuildContext context, GoRouterState state) { diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart index 80f2e396..3da37f6f 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart @@ -33,7 +33,7 @@ final class EngineSuggestionsProvider EngineSuggestions create() => EngineSuggestions(); } -String _$engineSuggestionsHash() => r'10d4a8a53d184b6c1d107d8634a2f662dfd297f1'; +String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f'; abstract class _$EngineSuggestions extends $StreamNotifier> { diff --git a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart index f7ccb856..afe61f29 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart @@ -19,7 +19,6 @@ */ import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; @@ -27,20 +26,8 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/utils/exit_app.dart'; -import 'package:weblibre/utils/ui_helper.dart'; const List experimentalSettingsSections = [ - SettingsSectionDefinition( - title: 'Web Push', - entries: [ - SettingsEntryDefinition( - title: 'Choose UnifiedPush Distributor', - subtitle: 'Select the app that delivers website push notifications', - keywords: ['notifications', 'push'], - child: _UnifiedPushDistributorTile(), - ), - ], - ), SettingsSectionDefinition( title: 'Runtime & Startup', entries: [ @@ -67,46 +54,13 @@ class ExperimentalSettingsScreen extends StatelessWidget { Widget build(BuildContext context) { return const SettingsDetailScaffold( title: 'Experimental', - subtitle: 'Push delivery, runtime isolation, and startup behavior.', + subtitle: 'Runtime isolation and startup behavior.', icon: MdiIcons.flaskOutline, sections: experimentalSettingsSections, ); } } -class _UnifiedPushDistributorTile extends StatelessWidget { - const _UnifiedPushDistributorTile(); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(MdiIcons.bellBadgeOutline), - title: const Text('Choose UnifiedPush Distributor'), - subtitle: const Text( - 'Select the app that should deliver website push notifications to WebLibre.', - ), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final success = await GeckoBrowserService() - .pickUnifiedPushDistributor(); - - if (!context.mounted) { - return; - } - - if (success) { - showInfoMessage(context, 'UnifiedPush distributor configured.'); - } else { - showErrorMessage( - context, - 'Could not configure UnifiedPush. Install a distributor and try again.', - ); - } - }, - ); - } -} - class _IsolatedProcessEnabledTile extends HookConsumerWidget { const _IsolatedProcessEnabledTile(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart index a0ee859d..7603cf82 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart @@ -34,6 +34,7 @@ import 'package:weblibre/features/settings/presentation/screens/search_settings. import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart'; +import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart'; class SettingsScreen extends HookWidget { const SettingsScreen({super.key}); @@ -135,6 +136,14 @@ _CategoryGroups _buildCategories() { sections: webContentSettingsSections, onTap: (context) => WebContentSettingsRoute().push(context), ), + _SettingsCategoryDefinition( + title: 'Notifications', + subtitle: 'Web push delivery, distributor, site subscriptions', + icon: MdiIcons.bellBadgeOutline, + keywords: const ['push', 'unifiedpush', 'ntfy', 'distributor'], + sections: webPushSettingsSections, + onTap: (context) => WebPushSettingsRoute().push(context), + ), _SettingsCategoryDefinition( title: 'Search', subtitle: 'Providers, bangs, search history', diff --git a/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart b/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart index abab3dc2..85cbe80f 100644 --- a/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart +++ b/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart @@ -33,7 +33,7 @@ Future showSwitchProfileDialog( icon: const Icon(Icons.warning), title: const Text('Switch User'), content: Text( - "Switching to User '$profileName' will require a restart of the Browser.\n\nPrivate tab data will be cleared on restart.", + "Switching to User '$profileName' will require a restart of the Browser. Web notifications for the inactive profile will be paused.\n\nPrivate tab data will be cleared on restart.", style: const TextStyle(fontWeight: FontWeight.bold), ), actions: [ diff --git a/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart b/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart index 74ef9dd7..b4cfd4df 100644 --- a/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart +++ b/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart @@ -55,9 +55,16 @@ Future handleSwitchProfile( ); if (shouldSwitch == true) { - await ref - .read(profileRepositoryProvider.notifier) - .switchProfile(profile.id); + try { + await ref + .read(profileRepositoryProvider.notifier) + .switchProfile(profile.id); + } catch (error) { + if (context.mounted) { + ui_helper.showErrorMessage(context, 'Could not switch profile: $error'); + } + return; + } await exitApp(ref.container); } } diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.dart index 97549685..b48298e6 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/profile.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/profile.dart @@ -20,8 +20,10 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:uuid/uuid.dart'; import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/core/logger.dart'; import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/user/data/models/auth_settings.dart'; +import 'package:weblibre/features/web_push/domain/providers.dart'; part 'profile.g.dart'; @@ -37,7 +39,23 @@ class ProfileRepository extends _$ProfileRepository { } Future switchProfile(String id) async { - await filesystem.setStartupProfile(UuidValue.withValidation(id)); + final profileId = UuidValue.withValidation(id).uuid; + final pushService = ref.read(pushServiceProvider); + + try { + await pushService.suspendForProfileSwitch(profileId); + } catch (error, stackTrace) { + try { + await pushService.renewRegistration(); + } catch (renewError, renewStackTrace) { + logger.e( + 'Failed to restore push registration after profile switch failure', + error: renewError, + stackTrace: renewStackTrace, + ); + } + Error.throwWithStackTrace(error, stackTrace); + } } Future createProfile({ diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart index 310a8afe..3f678c9a 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart @@ -33,7 +33,7 @@ final class ProfileRepositoryProvider ProfileRepository create() => ProfileRepository(); } -String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d'; +String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef'; abstract class _$ProfileRepository extends $AsyncNotifier> { FutureOr> build(); diff --git a/apps/weblibre/lib/features/web_push/domain/providers.dart b/apps/weblibre/lib/features/web_push/domain/providers.dart new file mode 100644 index 00000000..5a37c6fa --- /dev/null +++ b/apps/weblibre/lib/features/web_push/domain/providers.dart @@ -0,0 +1,216 @@ +/* + * 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:permission_handler/permission_handler.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +GeckoPushService pushService(Ref ref) { + final service = GeckoPushService(); + service.setUp(); + + ref.onDispose(() { + unawaited(service.dispose()); + }); + + return service; +} + +/// Current distributor selection and availability. +/// +/// Re-reads native state whenever the distributor acknowledges registration or +/// is uninstalled, so a distributor removed while this screen is open does not +/// leave a stale "configured" reading on screen. +/// +/// The native event stream is subscribed to *before* the initial snapshot is +/// requested: `statusChanges` does not replay, so a PENDING → READY transition +/// landing between the two would otherwise be lost and leave the screen stale. +/// If an event wins that race the snapshot is discarded rather than emitted +/// after it, since the snapshot is by then the older value. +@riverpod +Stream pushStatus(Ref ref) { + final service = ref.watch(pushServiceProvider); + + final controller = StreamController(); + var sawEvent = false; + + final subscription = service.statusChanges.listen( + (status) { + sawEvent = true; + if (!controller.isClosed) { + controller.add(status); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!controller.isClosed) { + controller.addError(error, stackTrace); + } + }, + ); + + unawaited( + service + .getPushStatus() + .then((status) { + if (!sawEvent && !controller.isClosed) { + controller.add(status); + } + }) + .onError((error, stackTrace) { + if (!sawEvent && !controller.isClosed) { + controller.addError(error, stackTrace); + } + }), + ); + + ref.onDispose(() { + unawaited(() async { + await subscription.cancel(); + await controller.close(); + }()); + }); + + return controller.stream; +} + +/// Subscriptions Gecko has created, keyed by site origin. +/// +/// Read-only: Gecko owns subscription state and exposes no revocation channel +/// to the app, so entries disappear only when the site itself unsubscribes or +/// its notification permission is revoked. +/// +/// Refetches only when [pushStatusProvider] emits (distributor change, endpoint +/// assigned, registration failure). There is no event for a subscription that is +/// created but still awaiting an endpoint, so such an entry only appears the next +/// time this provider is rebuilt — e.g. when the screen is reopened. +@riverpod +Future> pushSubscriptions(Ref ref) { + final service = ref.watch(pushServiceProvider); + + // A distributor change re-registers every known scope, so refetch alongside it. + ref.watch(pushStatusProvider); + + return service.getSubscriptions(); +} + +@riverpod +class PushDistributorMutation extends _$PushDistributorMutation { + Future? _operation; + String? _operationKey; + int _operationToken = 0; + + @override + Future build() async {} + + Future setDistributor(String packageName) { + return _mutate( + 'set:$packageName', + () => ref.read(pushServiceProvider).setDistributor(packageName), + ); + } + + Future removeDistributor() { + return _mutate( + 'remove', + () => ref.read(pushServiceProvider).removeDistributor(), + ); + } + + /// Runs a distributor mutation, keyed by [key]. + /// + /// An identical request already in flight (same [key]) shares the running + /// operation rather than issuing a duplicate native call. A *distinct* + /// request is serialized behind the in-flight one — never coalesced into it, + /// which would silently drop it and hand back the wrong operation's result. + Future _mutate(String key, Future Function() action) { + final running = _operation; + if (running != null && _operationKey == key) { + return running; + } + + final token = ++_operationToken; + final operation = _runMutation(running, action, token); + _operation = operation; + _operationKey = key; + return operation; + } + + Future _runMutation( + Future? previous, + Future Function() action, + int token, + ) async { + // Wait for any in-flight mutation to settle (ignoring its outcome) so + // distinct operations never overlap. + if (previous != null) { + await previous.then((_) {}, onError: (_, _) {}); + } + + state = const AsyncLoading(); + + try { + await action(); + if (ref.mounted) { + state = const AsyncData(null); + } + } catch (error, stackTrace) { + if (ref.mounted) { + state = AsyncError(error, stackTrace); + } + Error.throwWithStackTrace(error, stackTrace); + } finally { + // Only clear if no newer mutation has been chained after this one. + if (_operationToken == token) { + _operation = null; + _operationKey = null; + } + if (ref.mounted) { + ref.invalidate(pushStatusProvider); + ref.invalidate(pushSubscriptionsProvider); + } + } + } +} + +class NotificationPermissionService { + const NotificationPermissionService(); + + Future isGranted() => Permission.notification.isGranted; + + Future request() => Permission.notification.request(); + + Future openSettings() => openAppSettings(); +} + +@Riverpod(keepAlive: true) +NotificationPermissionService notificationPermissionService(Ref ref) { + return const NotificationPermissionService(); +} + +/// Whether the OS-level notification permission is granted. Without it a push +/// message still arrives but Gecko cannot display the resulting notification. +@riverpod +Future notificationPermissionGranted(Ref ref) { + return ref.watch(notificationPermissionServiceProvider).isGranted(); +} diff --git a/apps/weblibre/lib/features/web_push/domain/providers.g.dart b/apps/weblibre/lib/features/web_push/domain/providers.g.dart new file mode 100644 index 00000000..7cfe2275 --- /dev/null +++ b/apps/weblibre/lib/features/web_push/domain/providers.g.dart @@ -0,0 +1,341 @@ +// 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 + +@ProviderFor(pushService) +final pushServiceProvider = PushServiceProvider._(); + +final class PushServiceProvider + extends + $FunctionalProvider< + GeckoPushService, + GeckoPushService, + GeckoPushService + > + with $Provider { + PushServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GeckoPushService create(Ref ref) { + return pushService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GeckoPushService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pushServiceHash() => r'762a7893d6f0520cda6f566bdd1ac1679156286d'; + +/// Current distributor selection and availability. +/// +/// Re-reads native state whenever the distributor acknowledges registration or +/// is uninstalled, so a distributor removed while this screen is open does not +/// leave a stale "configured" reading on screen. +/// +/// The native event stream is subscribed to *before* the initial snapshot is +/// requested: `statusChanges` does not replay, so a PENDING → READY transition +/// landing between the two would otherwise be lost and leave the screen stale. +/// If an event wins that race the snapshot is discarded rather than emitted +/// after it, since the snapshot is by then the older value. + +@ProviderFor(pushStatus) +final pushStatusProvider = PushStatusProvider._(); + +/// Current distributor selection and availability. +/// +/// Re-reads native state whenever the distributor acknowledges registration or +/// is uninstalled, so a distributor removed while this screen is open does not +/// leave a stale "configured" reading on screen. +/// +/// The native event stream is subscribed to *before* the initial snapshot is +/// requested: `statusChanges` does not replay, so a PENDING → READY transition +/// landing between the two would otherwise be lost and leave the screen stale. +/// If an event wins that race the snapshot is discarded rather than emitted +/// after it, since the snapshot is by then the older value. + +final class PushStatusProvider + extends + $FunctionalProvider< + AsyncValue, + PushStatus, + Stream + > + with $FutureModifier, $StreamProvider { + /// Current distributor selection and availability. + /// + /// Re-reads native state whenever the distributor acknowledges registration or + /// is uninstalled, so a distributor removed while this screen is open does not + /// leave a stale "configured" reading on screen. + /// + /// The native event stream is subscribed to *before* the initial snapshot is + /// requested: `statusChanges` does not replay, so a PENDING → READY transition + /// landing between the two would otherwise be lost and leave the screen stale. + /// If an event wins that race the snapshot is discarded rather than emitted + /// after it, since the snapshot is by then the older value. + PushStatusProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushStatusProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushStatusHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return pushStatus(ref); + } +} + +String _$pushStatusHash() => r'406cf2a28628d5e5456758afc961cafb8c938b6e'; + +/// Subscriptions Gecko has created, keyed by site origin. +/// +/// Read-only: Gecko owns subscription state and exposes no revocation channel +/// to the app, so entries disappear only when the site itself unsubscribes or +/// its notification permission is revoked. +/// +/// Refetches only when [pushStatusProvider] emits (distributor change, endpoint +/// assigned, registration failure). There is no event for a subscription that is +/// created but still awaiting an endpoint, so such an entry only appears the next +/// time this provider is rebuilt — e.g. when the screen is reopened. + +@ProviderFor(pushSubscriptions) +final pushSubscriptionsProvider = PushSubscriptionsProvider._(); + +/// Subscriptions Gecko has created, keyed by site origin. +/// +/// Read-only: Gecko owns subscription state and exposes no revocation channel +/// to the app, so entries disappear only when the site itself unsubscribes or +/// its notification permission is revoked. +/// +/// Refetches only when [pushStatusProvider] emits (distributor change, endpoint +/// assigned, registration failure). There is no event for a subscription that is +/// created but still awaiting an endpoint, so such an entry only appears the next +/// time this provider is rebuilt — e.g. when the screen is reopened. + +final class PushSubscriptionsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Subscriptions Gecko has created, keyed by site origin. + /// + /// Read-only: Gecko owns subscription state and exposes no revocation channel + /// to the app, so entries disappear only when the site itself unsubscribes or + /// its notification permission is revoked. + /// + /// Refetches only when [pushStatusProvider] emits (distributor change, endpoint + /// assigned, registration failure). There is no event for a subscription that is + /// created but still awaiting an endpoint, so such an entry only appears the next + /// time this provider is rebuilt — e.g. when the screen is reopened. + PushSubscriptionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushSubscriptionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushSubscriptionsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return pushSubscriptions(ref); + } +} + +String _$pushSubscriptionsHash() => r'bebbaf964fbd48ddf56cf69af4a1d44a74e0f288'; + +@ProviderFor(PushDistributorMutation) +final pushDistributorMutationProvider = PushDistributorMutationProvider._(); + +final class PushDistributorMutationProvider + extends $AsyncNotifierProvider { + PushDistributorMutationProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pushDistributorMutationProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pushDistributorMutationHash(); + + @$internal + @override + PushDistributorMutation create() => PushDistributorMutation(); +} + +String _$pushDistributorMutationHash() => + r'5797ca731c90c1e06e089fb71ad602aecda59634'; + +abstract class _$PushDistributorMutation extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(notificationPermissionService) +final notificationPermissionServiceProvider = + NotificationPermissionServiceProvider._(); + +final class NotificationPermissionServiceProvider + extends + $FunctionalProvider< + NotificationPermissionService, + NotificationPermissionService, + NotificationPermissionService + > + with $Provider { + NotificationPermissionServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationPermissionServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationPermissionServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NotificationPermissionService create(Ref ref) { + return notificationPermissionService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NotificationPermissionService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$notificationPermissionServiceHash() => + r'f6c55f04ace1145f0925adc73c3a13b93a3afea1'; + +/// Whether the OS-level notification permission is granted. Without it a push +/// message still arrives but Gecko cannot display the resulting notification. + +@ProviderFor(notificationPermissionGranted) +final notificationPermissionGrantedProvider = + NotificationPermissionGrantedProvider._(); + +/// Whether the OS-level notification permission is granted. Without it a push +/// message still arrives but Gecko cannot display the resulting notification. + +final class NotificationPermissionGrantedProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// Whether the OS-level notification permission is granted. Without it a push + /// message still arrives but Gecko cannot display the resulting notification. + NotificationPermissionGrantedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationPermissionGrantedProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationPermissionGrantedHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return notificationPermissionGranted(ref); + } +} + +String _$notificationPermissionGrantedHash() => + r'3344fb6996f7577cddb5c1dc24ae262f2724750e'; diff --git a/apps/weblibre/lib/features/web_push/presentation/screens/web_push_settings.dart b/apps/weblibre/lib/features/web_push/presentation/screens/web_push_settings.dart new file mode 100644 index 00000000..b6477338 --- /dev/null +++ b/apps/weblibre/lib/features/web_push/presentation/screens/web_push_settings.dart @@ -0,0 +1,435 @@ +/* + * 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:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; +import 'package:weblibre/features/web_push/domain/providers.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +const List webPushSettingsSections = [ + SettingsSectionDefinition( + title: 'Delivery', + entries: [ + SettingsEntryDefinition( + title: 'UnifiedPush Distributor', + subtitle: 'The app that delivers website push notifications', + keywords: ['notifications', 'push', 'unifiedpush', 'ntfy'], + child: _DistributorTile(), + ), + SettingsEntryDefinition( + title: 'Notification Permission', + subtitle: 'Required to display website notifications', + keywords: ['notifications', 'permission'], + child: _NotificationPermissionTile(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Subscriptions', + entries: [ + SettingsEntryDefinition( + title: 'Site Subscriptions', + subtitle: 'Websites subscribed to push notifications', + keywords: ['sites', 'subscriptions'], + child: _SubscriptionList(), + ), + ], + ), +]; + +class WebPushSettingsScreen extends StatelessWidget { + const WebPushSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const SettingsDetailScaffold( + title: 'Notifications', + subtitle: 'Web push delivery, distributor, and site subscriptions.', + icon: MdiIcons.bellBadgeOutline, + sections: webPushSettingsSections, + ); + } +} + +extension on PushDistributorStatus { + String get label => switch (this) { + PushDistributorStatus.noneAvailable => 'No distributor installed', + PushDistributorStatus.notSelected => 'No distributor selected', + PushDistributorStatus.pending => 'Waiting for distributor', + PushDistributorStatus.ready => 'Active', + PushDistributorStatus.unavailable => 'Distributor uninstalled', + }; + + String get description => switch (this) { + PushDistributorStatus.noneAvailable => + 'Install a UnifiedPush distributor such as ntfy to receive website push notifications.', + PushDistributorStatus.notSelected => + 'Choose which app should deliver website push notifications to WebLibre.', + PushDistributorStatus.pending => + 'The selected app has not confirmed registration yet. This usually resolves on its own.', + PushDistributorStatus.ready => + 'Website push notifications are delivered through this app.', + PushDistributorStatus.unavailable => + 'The app that delivered push notifications was uninstalled. Website notifications will not arrive until you choose another.', + }; + + bool get isProblem => + this == PushDistributorStatus.unavailable || + this == PushDistributorStatus.noneAvailable; +} + +class _DistributorTile extends HookConsumerWidget { + const _DistributorTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(pushStatusProvider); + final mutation = ref.watch(pushDistributorMutationProvider); + final isMutating = mutation.isLoading; + + return status.when( + loading: () => const ListTile( + leading: Icon(MdiIcons.bellBadgeOutline), + title: Text('UnifiedPush Distributor'), + subtitle: Text('Checking…'), + ), + error: (error, _) => ListTile( + leading: const Icon(MdiIcons.alertCircleOutline), + title: const Text('UnifiedPush Distributor'), + subtitle: Text('Could not read push status: $error'), + ), + data: (pushStatus) { + final theme = Theme.of(context); + final current = pushStatus.current; + final failure = pushStatus.lastError; + final isProblem = pushStatus.status.isProblem || failure != null; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + isProblem + ? MdiIcons.bellRemoveOutline + : MdiIcons.bellBadgeOutline, + color: isProblem ? theme.colorScheme.error : null, + ), + title: const Text('UnifiedPush Distributor'), + subtitle: Text( + isMutating + ? 'Updating distributor...' + : current != null + ? '${current.label ?? current.packageName} — ${pushStatus.status.label}' + : pushStatus.status.label, + style: isProblem + ? TextStyle(color: theme.colorScheme.error) + : null, + ), + trailing: isMutating + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + onTap: isMutating + ? null + : () => _pickDistributor(context, ref, pushStatus), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + failure == null + ? pushStatus.status.description + : 'Push delivery may be temporarily unavailable while the distributor registration recovers.', + style: theme.textTheme.bodySmall, + ), + ), + ), + if (failure != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Last registration error: $failure', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ), + if (current != null) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: TextButton.icon( + icon: const Icon(MdiIcons.bellOffOutline), + label: Text( + isMutating ? 'Disabling web push...' : 'Disable web push', + ), + onPressed: isMutating + ? null + : () => _removeDistributor(context, ref), + ), + ), + ), + ], + ); + }, + ); + } + + /// Picks a distributor from [pushStatus]'s available list. + /// + /// Deliberately a Dart dialog rather than the connector's own picker: that one + /// saves the selection against a context that is not the profile context, so + /// the choice would be invisible to the rest of the push stack. + Future _pickDistributor( + BuildContext context, + WidgetRef ref, + PushStatus pushStatus, + ) async { + if (pushStatus.available.isEmpty) { + showErrorMessage( + context, + 'No UnifiedPush distributor installed. Install one, such as ntfy, and try again.', + ); + return; + } + + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('Choose distributor'), + children: [ + for (final distributor in pushStatus.available) + SimpleDialogOption( + onPressed: () => Navigator.pop(context, distributor), + child: ListTile( + leading: Icon( + distributor.packageName == pushStatus.current?.packageName + ? MdiIcons.checkCircle + : MdiIcons.circleOutline, + ), + title: Text(distributor.label ?? distributor.packageName), + subtitle: Text(distributor.packageName), + ), + ), + ], + ), + ); + + if (selected == null || !context.mounted) { + return; + } + + try { + await ref + .read(pushDistributorMutationProvider.notifier) + .setDistributor(selected.packageName); + if (context.mounted) { + showInfoMessage(context, 'UnifiedPush distributor configured.'); + } + } catch (error) { + if (context.mounted) { + showErrorMessage(context, 'Could not configure distributor: $error'); + } + } + } + + Future _removeDistributor(BuildContext context, WidgetRef ref) async { + try { + await ref + .read(pushDistributorMutationProvider.notifier) + .removeDistributor(); + if (context.mounted) { + showInfoMessage(context, 'Web push disabled.'); + } + } catch (error) { + if (context.mounted) { + showErrorMessage(context, 'Could not disable web push: $error'); + } + } + } +} + +class _NotificationPermissionTile extends HookConsumerWidget { + const _NotificationPermissionTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isUpdating = useState(false); + final granted = ref.watch(notificationPermissionGrantedProvider); + final theme = Theme.of(context); + + useOnAppLifecycleStateChange((previous, current) { + if (current == AppLifecycleState.resumed && !isUpdating.value) { + ref.invalidate(notificationPermissionGrantedProvider); + } + }); + + return granted.when( + loading: () => const ListTile( + leading: Icon(MdiIcons.bellBadgeOutline), + title: Text('Notification Permission'), + subtitle: Text('Checking…'), + ), + error: (error, _) => ListTile( + leading: Icon( + MdiIcons.alertCircleOutline, + color: theme.colorScheme.error, + ), + title: const Text('Notification Permission'), + subtitle: Text('Could not read permission state: $error'), + ), + data: (isGranted) { + if (isGranted) { + return const ListTile( + leading: Icon(MdiIcons.bellCheckOutline), + title: Text('Notification Permission'), + subtitle: Text('Granted'), + ); + } + + return ListTile( + leading: Icon( + MdiIcons.bellRemoveOutline, + color: theme.colorScheme.error, + ), + title: const Text('Notification Permission'), + subtitle: Text( + 'Denied. Push messages arrive but no notification can be shown.', + style: TextStyle(color: theme.colorScheme.error), + ), + trailing: TextButton( + onPressed: isUpdating.value + ? null + : () async { + isUpdating.value = true; + try { + final service = ref.read( + notificationPermissionServiceProvider, + ); + final status = await service.request(); + if (status.isPermanentlyDenied && + !await service.openSettings()) { + throw StateError('Could not open app settings'); + } + } catch (error) { + if (context.mounted) { + showErrorMessage( + context, + 'Could not update notification permission: $error', + ); + } + } finally { + if (context.mounted) { + ref.invalidate(notificationPermissionGrantedProvider); + isUpdating.value = false; + } + } + }, + child: isUpdating.value + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : const Text('Grant'), + ), + ); + }, + ); + } +} + +class _SubscriptionList extends HookConsumerWidget { + const _SubscriptionList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final subscriptions = ref.watch(pushSubscriptionsProvider); + final distributorReady = + ref.watch(pushStatusProvider).value?.status == + PushDistributorStatus.ready; + + return subscriptions.when( + loading: () => const ListTile( + leading: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + title: Text('Loading subscriptions…'), + ), + error: (error, _) => ListTile( + leading: const Icon(MdiIcons.alertCircleOutline), + title: const Text('Could not read subscriptions'), + subtitle: Text('$error'), + ), + data: (items) { + if (items.isEmpty) { + return const ListTile( + leading: Icon(MdiIcons.webOff), + title: Text('No site subscriptions'), + subtitle: Text( + 'Websites you allow to send notifications will appear here.', + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final subscription in items) + ListTile( + leading: Icon( + subscription.hasEndpoint ? MdiIcons.web : MdiIcons.webClock, + ), + title: Text(subscription.scope), + subtitle: Text( + subscription.hasEndpoint + ? distributorReady + ? 'Active' + : 'Endpoint saved; delivery is paused until the distributor is ready' + : 'Waiting for the distributor to assign an endpoint', + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'To stop a site from sending notifications, revoke its ' + 'notification permission in the site settings.', + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/apps/weblibre/test/features/web_push/domain/providers_test.dart b/apps/weblibre/test/features/web_push/domain/providers_test.dart new file mode 100644 index 00000000..102ff8e7 --- /dev/null +++ b/apps/weblibre/test/features/web_push/domain/providers_test.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/web_push/domain/providers.dart'; + +void main() { + test('discards stale initial status after a native event', () async { + final service = _FakePushService(); + final container = _container(service); + addTearDown(container.dispose); + addTearDown(service.close); + final values = >[]; + final subscription = container.listen( + pushStatusProvider, + (_, next) => values.add(next), + fireImmediately: true, + ); + addTearDown(subscription.close); + await pumpEventQueue(); + + service.emit(_status(PushDistributorStatus.ready)); + service.initialStatus.complete(_status(PushDistributorStatus.pending)); + await pumpEventQueue(); + + expect(values.where((value) => value.hasError), isEmpty); + expect(values.last.value?.status, PushDistributorStatus.ready); + }); + + test('discards stale initial error after a native event', () async { + final service = _FakePushService(); + final container = _container(service); + addTearDown(container.dispose); + addTearDown(service.close); + final values = >[]; + final subscription = container.listen( + pushStatusProvider, + (_, next) => values.add(next), + fireImmediately: true, + ); + addTearDown(subscription.close); + await pumpEventQueue(); + + service.emit(_status(PushDistributorStatus.ready)); + service.initialStatus.completeError(StateError('stale snapshot failure')); + await pumpEventQueue(); + + expect(values.where((value) => value.hasError), isEmpty); + expect(values.last.value?.status, PushDistributorStatus.ready); + }); + + test('mutation shares duplicate work and exposes failure', () async { + final service = _FakePushService(); + final setCompleter = Completer(); + service.setDistributorResult = setCompleter.future; + final container = _container(service); + addTearDown(container.dispose); + addTearDown(service.close); + final subscription = container.listen( + pushDistributorMutationProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await container.read(pushDistributorMutationProvider.future); + + final notifier = container.read(pushDistributorMutationProvider.notifier); + final first = notifier.setDistributor('org.example.distributor'); + final duplicate = notifier.setDistributor('org.example.distributor'); + + expect(service.setDistributorCalls, 1); + expect(container.read(pushDistributorMutationProvider).isLoading, isTrue); + + setCompleter.completeError(StateError('registration failed')); + await expectLater(first, throwsA(isA())); + await expectLater(duplicate, throwsA(isA())); + + expect(container.read(pushDistributorMutationProvider).hasError, isTrue); + }); + + test('remove failure is exposed by the mutation controller', () async { + final service = _FakePushService() + ..removeDistributorError = StateError('remove failed'); + final container = _container(service); + addTearDown(container.dispose); + addTearDown(service.close); + final subscription = container.listen( + pushDistributorMutationProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await container.read(pushDistributorMutationProvider.future); + + await expectLater( + container + .read(pushDistributorMutationProvider.notifier) + .removeDistributor(), + throwsA(isA()), + ); + + expect(service.removeDistributorCalls, 1); + expect(container.read(pushDistributorMutationProvider).hasError, isTrue); + }); + + test('serializes distinct mutations instead of dropping them', () async { + final service = _FakePushService(); + final setCompleter = Completer(); + service.setDistributorResult = setCompleter.future; + final container = _container(service); + addTearDown(container.dispose); + addTearDown(service.close); + final subscription = container.listen( + pushDistributorMutationProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await container.read(pushDistributorMutationProvider.future); + + final notifier = container.read(pushDistributorMutationProvider.notifier); + final set = notifier.setDistributor('org.example.distributor'); + final remove = notifier.removeDistributor(); + + // The distinct remove must not be coalesced into the in-flight set; it is + // queued behind it and only runs once the set settles. + expect(service.setDistributorCalls, 1); + expect(service.removeDistributorCalls, 0); + + setCompleter.complete(); + await set; + await remove; + + // Both distinct operations actually ran. + expect(service.setDistributorCalls, 1); + expect(service.removeDistributorCalls, 1); + }); +} + +ProviderContainer _container(_FakePushService service) { + return ProviderContainer( + overrides: [pushServiceProvider.overrideWithValue(service)], + ); +} + +PushStatus _status(PushDistributorStatus status) { + return PushStatus(status: status, available: const []); +} + +class _FakePushService extends GeckoPushService { + final statusController = StreamController.broadcast(sync: true); + final initialStatus = Completer(); + Future setDistributorResult = Future.value(); + Object? removeDistributorError; + int setDistributorCalls = 0; + int removeDistributorCalls = 0; + + @override + Stream get statusChanges => statusController.stream; + + @override + Future getPushStatus() => initialStatus.future; + + @override + Future setDistributor(String packageName) { + setDistributorCalls++; + return setDistributorResult; + } + + @override + Future removeDistributor() async { + removeDistributorCalls++; + if (removeDistributorError case final error?) { + throw error; + } + } + + void emit(PushStatus status) => statusController.add(status); + + Future close() => statusController.close(); +} diff --git a/apps/weblibre/test/features/web_push/presentation/screens/web_push_settings_test.dart b/apps/weblibre/test/features/web_push/presentation/screens/web_push_settings_test.dart new file mode 100644 index 00000000..4b02a073 --- /dev/null +++ b/apps/weblibre/test/features/web_push/presentation/screens/web_push_settings_test.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:weblibre/features/web_push/domain/providers.dart'; +import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart'; + +void main() { + testWidgets('refreshes notification permission when the app resumes', ( + tester, + ) async { + final permissionService = _FakePermissionService(); + await _pumpSettings(tester, permissionService: permissionService); + + expect(permissionService.checkCalls, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + expect(permissionService.checkCalls, 2); + }); + + testWidgets('permission request prevents duplicates and reports errors', ( + tester, + ) async { + final request = Completer(); + final permissionService = _FakePermissionService( + requestResult: request.future, + ); + await _pumpSettings(tester, permissionService: permissionService); + + await tester.tap(find.text('Grant')); + await tester.pump(); + await tester.tap(find.byType(TextButton).last); + await tester.pump(); + + expect(permissionService.requestCalls, 1); + + request.completeError(StateError('permission plugin failed')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Could not update notification permission'), + findsOneWidget, + ); + }); + + testWidgets('distributor mutation reports errors without showing success', ( + tester, + ) async { + final pushService = _FakePushService( + setDistributorError: StateError('registration failed'), + ); + await _pumpSettings(tester, pushService: pushService); + + await tester.tap(find.text('UnifiedPush Distributor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Test Distributor')); + await tester.pumpAndSettle(); + + expect(pushService.setDistributorCalls, 1); + expect( + find.textContaining('Could not configure distributor'), + findsOneWidget, + ); + expect(find.text('UnifiedPush distributor configured.'), findsNothing); + }); +} + +Future _pumpSettings( + WidgetTester tester, { + _FakePushService? pushService, + _FakePermissionService? permissionService, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pushServiceProvider.overrideWithValue( + pushService ?? _FakePushService(), + ), + notificationPermissionServiceProvider.overrideWithValue( + permissionService ?? _FakePermissionService(), + ), + ], + child: const MaterialApp(home: WebPushSettingsScreen()), + ), + ); + await tester.pumpAndSettle(); +} + +class _FakePushService extends GeckoPushService { + final Object? setDistributorError; + int setDistributorCalls = 0; + + _FakePushService({this.setDistributorError}); + + @override + Stream get statusChanges => const Stream.empty(); + + @override + Future getPushStatus() async { + return PushStatus( + status: PushDistributorStatus.notSelected, + available: [ + PushDistributor( + packageName: 'org.example.distributor', + label: 'Test Distributor', + ), + ], + ); + } + + @override + Future> getSubscriptions() async => const []; + + @override + Future setDistributor(String packageName) async { + setDistributorCalls++; + if (setDistributorError case final error?) { + throw error; + } + } +} + +class _FakePermissionService extends NotificationPermissionService { + final Future? requestResult; + int checkCalls = 0; + int requestCalls = 0; + + _FakePermissionService({this.requestResult}); + + @override + Future isGranted() async { + checkCalls++; + return false; + } + + @override + Future request() { + requestCalls++; + return requestResult ?? Future.value(PermissionStatus.denied); + } +} diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle index 7d7ad20c..43b6cd85 100644 --- a/packages/flutter_mozilla_components/android/build.gradle +++ b/packages/flutter_mozilla_components/android/build.gradle @@ -152,6 +152,7 @@ dependencies { //https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo implementation 'androidx.activity:activity-ktx:1.13.0' implementation 'androidx.paging:paging-runtime-ktx:3.5.0' + implementation 'androidx.work:work-runtime:2.11.2' testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.mockito:mockito-core:5.23.0") diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt index 6e146511..0db4e56d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt @@ -20,7 +20,12 @@ package eu.weblibre.flutter_mozilla_components import android.content.Context +import android.util.AtomicFile import java.io.File +import java.io.FileNotFoundException +import java.util.UUID +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock object ActiveProfile { @Volatile @@ -44,11 +49,50 @@ object ActiveProfile { * Resolve the active profile prefix from disk. * Called in Application.onCreate() to handle cold-start WorkManager scenarios. */ - fun resolveFromDisk(context: Context) { + fun resolveFromDisk(context: Context): ProfileContext? = resolveContext(context) + + /** Resolve the active profile without constructing browser components. */ + @Synchronized + fun resolveContext(context: Context): ProfileContext? { val profileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE) - if (!profileFile.exists()) return - val uuid = profileFile.readText().trim().ifEmpty { return } + val uuid = try { + AtomicFile(profileFile).openRead().bufferedReader().use { it.readText() }.trim() + } catch (_: FileNotFoundException) { + return null + }.ifEmpty { return null } val relativePath = "${PwaConstants.PROFILES_DIR_NAME}/${PwaConstants.PROFILE_DIR_PREFIX}$uuid" + if (!File(context.filesDir, relativePath).isDirectory) return null + prefix = File(relativePath).name + return ProfileContext(context.applicationContext, relativePath) + } + + /** Atomically select the profile used by the next browser process. */ + @Synchronized + fun switchTo(context: Context, profileId: String) { + val normalizedId = UUID.fromString(profileId).toString() + require(normalizedId == profileId.lowercase()) { "Invalid profile id" } + + val relativePath = + "${PwaConstants.PROFILES_DIR_NAME}/${PwaConstants.PROFILE_DIR_PREFIX}$normalizedId" + require(File(context.filesDir, relativePath).isDirectory) { "Profile does not exist" } + + val profileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE) + profileFile.parentFile?.mkdirs() + val atomicFile = AtomicFile(profileFile) + val output = atomicFile.startWrite() + try { + output.write(normalizedId.toByteArray(Charsets.UTF_8)) + atomicFile.finishWrite(output) + } catch (error: Throwable) { + atomicFile.failWrite(output) + throw error + } prefix = File(relativePath).name } + + /** Prevent profile switches from crossing active-profile background work. */ + internal suspend fun withProfileLock(block: suspend () -> T): T = + profileMutex.withLock { block() } + + private val profileMutex = Mutex() } 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 567f7b2d..443c2428 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 @@ -12,7 +12,7 @@ import eu.weblibre.flutter_mozilla_components.components.Core import eu.weblibre.flutter_mozilla_components.components.BackgroundServices import eu.weblibre.flutter_mozilla_components.components.Events import eu.weblibre.flutter_mozilla_components.components.Features -import eu.weblibre.flutter_mozilla_components.components.Push +import eu.weblibre.flutter_mozilla_components.push.Push import eu.weblibre.flutter_mozilla_components.components.Search import eu.weblibre.flutter_mozilla_components.components.Services import eu.weblibre.flutter_mozilla_components.components.UseCases @@ -75,7 +75,11 @@ class Components(val profileApplicationContext: ProfileContext, } val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) } val search by lazy { Search(profileApplicationContext, core, useCases) } - val push by lazy { Push(this) } + private val pushDelegate = lazy { Push(this) } + val push: Push + get() = pushDelegate.value + internal val existingPush: Push? + get() = pushDelegate.takeIf { it.isInitialized() }?.value var mainBrowserEngineView: EngineView? = null var externalAppEngineView: EngineView? = null diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index a7e453ed..40f233c1 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -11,6 +11,7 @@ import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -43,7 +44,12 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger) + GeckoPushApi.setUp(binding.binaryMessenger, null) + browserApi.disposePushApi() GlobalComponents.historyEvents = null + // The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching + // onto a dead messenger. Failures are still retained on Push.lastError. + GlobalComponents.pushEvents = null } override fun onAttachedToActivity(binding: ActivityPluginBinding) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 6d36f3ec..3b2ef45a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -18,6 +18,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents @@ -33,11 +34,13 @@ import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge +import eu.weblibre.flutter_mozilla_components.push.Push import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider import mozilla.components.browser.session.storage.RecoverableBrowserState import mozilla.components.browser.state.action.RestoreCompleteAction @@ -64,6 +67,8 @@ private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST = private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists" private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF = "browser.weblibre.excludedHistoryContextIds" +private const val PROFILE_SWITCH_PERSIST_TIMEOUT_MS = 3000L +private const val PROFILE_SWITCH_DETACH_TIMEOUT_MS = 2000L object GlobalComponents { private var _components: Components? = null @@ -74,6 +79,30 @@ object GlobalComponents { val components: Components? get() = _components + internal val isExternalMode: Boolean + get() = currentMode == ComponentsMode.EXTERNAL + + /** Resolve a live Push only when it belongs to the supplied profile context. */ + fun pushForProfile(context: Context): Push? { + val profilePath = (context as? ProfileContext)?.relativePath ?: return null + val current = _components ?: return null + if (current.profileApplicationContext.relativePath != profilePath) return null + return current.existingPush?.takeUnless { it.isClosed } + } + + fun resolveActiveProfileContext(context: Context): ProfileContext? = + runCatching { ActiveProfile.resolveContext(context.applicationContext) }.getOrNull() + + fun closePush() { + _components?.existingPush?.close() + } + + fun tearDown() { + _components?.existingPush?.close() + _components = null + currentMode = null + } + enum class ComponentsMode { FULL, EXTERNAL, @@ -116,6 +145,11 @@ object GlobalComponents { // container contextIds but skips Dart relation emits. var historyEvents: GeckoHistoryEvents? = null + // Native -> Dart UnifiedPush registration lifecycle. Null when push events + // arrive with no Flutter engine attached (the UnifiedPushReceiver cold-start + // path), in which case failures are logged natively only. + var pushEvents: GeckoPushEvents? = null + // Gecko contextIds of containers with hard exclude-from-history enabled. // Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places // write for visits resolved to one of these containers. @@ -334,6 +368,40 @@ object GlobalComponents { emptyList() } + previousComponents?.existingPush?.let { previousPush -> + if (!isSameProfile) { + val targetProfileId = File(applicationContext.relativePath).name + .removePrefix(PwaConstants.PROFILE_DIR_PREFIX) + runBlocking { + // Persist the switch while holding the profile lock so an + // in-flight worker or receiver cannot straddle it. Bound only + // the wait for exclusivity; once the atomic write starts it + // must return a definitive result. Failure aborts setup before + // B's components are created. + check( + previousPush.persistProfileSwitch( + targetProfileId, + PROFILE_SWITCH_PERSIST_TIMEOUT_MS, + ), + ) { "Timed out waiting to persist profile switch to $targetProfileId" } + // Detaching the now-inactive profile's transport is best-effort + // cleanup; bound it so a slow distributor cannot stall setup. + runCatching { + withTimeoutOrNull(PROFILE_SWITCH_DETACH_TIMEOUT_MS) { + previousPush.detachTransportForSwitch() + } ?: Logger.warn("Timed out detaching push transport during switch") + }.onFailure { + Logger.warn("Failed to detach push transport during switch", it) + } + } + // Closing may need the same dispatcher as a timed-out detach. + // Mark it closed now, but drain old-profile resources off-main. + previousPush.closeDeferred() + } else { + previousPush.close() + } + } + val newComponents = Components( applicationContext, flutterEvents, 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 2e82cf32..0844fbdf 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 @@ -12,6 +12,9 @@ import java.io.File class ProfileContext(private val base: Context, val relativePath: String) : ContextWrapper(base) { + internal val rootApplicationContext: Context + get() = base.applicationContext + private val subfolderRoot = File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default 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 5278ca13..6d75b2a3 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 @@ -47,6 +47,8 @@ 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.GeckoPushApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents @@ -132,6 +134,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { private var activity: Activity? = null private var isPlatformViewRegistered = false + private var pushApi: GeckoPushApiImpl? = null private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding private lateinit var _flutterEvents: GeckoStateEvents @@ -167,6 +170,11 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { this.activity = activity } + fun disposePushApi() { + pushApi?.dispose() + pushApi = null + } + fun detachActivity() { this.activity = null } @@ -272,6 +280,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GlobalComponents.historyEvents = GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger) + // Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore + // surface a registration failure before this sink would otherwise exist. + GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger) + GlobalComponents.setUp( profileApplicationContext, _flutterEvents, @@ -362,6 +374,15 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoGestureApiImpl() ) + // UnifiedPush distributor management. The event sink was installed above, before + // GlobalComponents.setUp initialized push. + pushApi?.dispose() + pushApi = GeckoPushApiImpl() + GeckoPushApi.setUp( + _flutterPluginBinding.binaryMessenger, + pushApi + ) + ReaderViewEvents.setUp( _flutterPluginBinding.binaryMessenger, components.events.readerViewEvents @@ -496,23 +517,6 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { currentActivity.startActivity(intent) } - override fun pickUnifiedPushDistributor(callback: (Result) -> Unit) { - val currentActivity = activity - if (currentActivity == null) { - callback(Result.success(false)) - return - } - - runCatching { - components.push.pickDistributor(currentActivity) { success -> - callback(Result.success(success)) - } - }.onFailure { error -> - logger.error("$TAG: Failed to pick UnifiedPush distributor", error) - callback(Result.failure(error)) - } - } - override fun shutdown() { logger.debug("$TAG: Shutting down GeckoView engine") @@ -538,6 +542,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { // 2. Stop component-level services try { GlobalComponents.stopPrivateTabsNotificationFeature() + disposePushApi() + GlobalComponents.closePush() GlobalComponents.components?.let { components -> // Stop the FxA web channel feature @@ -555,6 +561,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { EngineProvider.shutdown() } catch (e: Exception) { logger.error("$TAG: Error shutting down GeckoRuntime", e) + } finally { + GlobalComponents.tearDown() } isGeckoInitialized = false diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPushApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPushApiImpl.kt new file mode 100644 index 00000000..b1978c82 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPushApiImpl.kt @@ -0,0 +1,82 @@ +/* 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 eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi +import eu.weblibre.flutter_mozilla_components.pigeons.PushStatus +import eu.weblibre.flutter_mozilla_components.pigeons.PushSubscription +import eu.weblibre.flutter_mozilla_components.push.toPigeon +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** UnifiedPush distributor management for the settings UI. */ +class GeckoPushApiImpl : GeckoPushApi { + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private val push + get() = requireNotNull(GlobalComponents.components) { "Components not initialized" }.push + + override fun getPushStatus(callback: (Result) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { push.status() }.toPigeon() + } + } + + override fun setDistributor(packageName: String, callback: (Result) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { push.setDistributor(packageName) } + } + } + + override fun removeDistributor(callback: (Result) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { push.removeDistributor() } + } + } + + override fun renewRegistration(callback: (Result) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { push.renewRegistration() } + } + } + + override fun suspendForProfileSwitch(targetProfileId: String, callback: (Result) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { push.suspendForProfileSwitch(targetProfileId) } + } + } + + override fun getSubscriptions(callback: (Result>) -> Unit) { + respond(callback) { + withContext(Dispatchers.IO) { + push.subscriptions().map { + PushSubscription(scope = it.scope, hasEndpoint = it.hasEndpoint) + } + } + } + } + + private fun respond(callback: (Result) -> Unit, block: suspend () -> T) { + coroutineScope.launch { + try { + callback(Result.success(block())) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + callback(Result.failure(error)) + } + } + } + + fun dispose() { + coroutineScope.cancel() + } +} 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 af3c230c..63509ece 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 @@ -33,6 +33,7 @@ import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddlewar import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.push.WebNotificationDrainCoordinator import kotlinx.coroutines.FlowPreview import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage import mozilla.components.browser.engine.gecko.util.EngineDownloadDelegate @@ -225,6 +226,11 @@ class Core( HistoryMetadataService(storage = historyStorage) } + // Wraps the WebNotificationFeature delegate so headless push deliveries can + // wait for the service worker to actually post its notification before the + // process loses foreground priority. Installed when [store] is created. + val webNotificationDrainCoordinator = WebNotificationDrainCoordinator() + @OptIn(FlowPreview::class) val store by lazy { BrowserStore( @@ -282,7 +288,11 @@ class Core( icons.install(engine, this) - WebNotificationFeature( + // WebNotificationFeature self-registers as the engine's notification + // delegate in its init; immediately wrap it with the drain + // coordinator so headless deliveries observe onShowNotification while + // notifications still display exactly as before. + val webNotificationFeature = WebNotificationFeature( context, engine, icons, @@ -291,6 +301,8 @@ class Core( NotificationActivity::class.java, notificationsDelegate = components.notificationsDelegate, ) + webNotificationDrainCoordinator.delegate = webNotificationFeature + engine.registerWebNotificationDelegate(webNotificationDrainCoordinator) MediaSessionFeature(context, MediaSessionService::class.java, this).start() } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Push.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Push.kt deleted file mode 100644 index 36b626d1..00000000 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Push.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* 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.components - -import android.app.Activity -import eu.weblibre.flutter_mozilla_components.Components -import eu.weblibre.flutter_mozilla_components.push.WebPushEngineIntegration -import java.util.concurrent.atomic.AtomicBoolean -import org.ironfoxoss.unifiedpush.UnifiedPushFeature -import org.unifiedpush.android.connector.UnifiedPush - -/** - * Component group for web push services backed by UnifiedPush. - */ -class Push( - private val components: Components, -) { - private val initialized = AtomicBoolean(false) - - private val feature by lazy { - UnifiedPushFeature( - context = components.profileApplicationContext, - disableRateLimit = true, - ) - } - - private val webPushEngineIntegration by lazy { - WebPushEngineIntegration(components.core.engine, feature) - } - - fun initialize() { - if (!initialized.compareAndSet(false, true)) { - return - } - - // Ensure the store-side WebNotificationFeature is installed before push events arrive. - components.core.store - webPushEngineIntegration.start() - feature.initialize() - } - - fun pickDistributor(activity: Activity, callback: (Boolean) -> Unit) { - initialize() - UnifiedPush.tryPickDistributor(activity) { success -> - if (success) { - feature.renewRegistration() - } - callback(success) - } - } -} 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 570ebc8d..fc33c50b 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 @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// Autogenerated from Pigeon (v27.1.0), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -795,6 +795,29 @@ enum class AutoplayStatus(val raw: Int) { } } +/** Lifecycle state of the selected UnifiedPush distributor. */ +enum class PushDistributorStatus(val raw: Int) { + /** No distributor app is installed on the device. */ + NONE_AVAILABLE(0), + /** Distributors are installed but the user has not chosen one. */ + NOT_SELECTED(1), + /** A distributor is chosen but has not acknowledged our registration yet. */ + PENDING(2), + /** A distributor is chosen and has acknowledged our registration. */ + READY(3), + /** + * A distributor was chosen previously but is no longer installed. Web push + * is dead in this state and there is no fallback transport. + */ + UNAVAILABLE(4); + + companion object { + fun ofRaw(raw: Int): PushDistributorStatus? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** * Translation options that map to the Gecko Translations Options. * @@ -834,6 +857,9 @@ data class TranslationOptions ( result = 31 * result + GeckoPigeonUtils.deepHash(this.downloadModel) return result } + override fun toString(): String { + return "TranslationOptions(downloadModel=$downloadModel)" + } } /** @@ -876,6 +902,9 @@ data class TranslationLanguage ( result = 31 * result + GeckoPigeonUtils.deepHash(this.localizedDisplayName) return result } + override fun toString(): String { + return "TranslationLanguage(code=$code, localizedDisplayName=$localizedDisplayName)" + } } /** @@ -922,6 +951,9 @@ data class TranslationDetectedLanguages ( result = 31 * result + GeckoPigeonUtils.deepHash(this.userPreferredLangTag) return result } + override fun toString(): String { + return "TranslationDetectedLanguages(documentLangTag=$documentLangTag, supportedDocumentLang=$supportedDocumentLang, userPreferredLangTag=$userPreferredLangTag)" + } } /** @@ -964,6 +996,9 @@ data class TranslationPair ( result = 31 * result + GeckoPigeonUtils.deepHash(this.toLanguage) return result } + override fun toString(): String { + return "TranslationPair(fromLanguage=$fromLanguage, toLanguage=$toLanguage)" + } } /** @@ -1010,6 +1045,9 @@ data class TranslationEngineStateData ( result = 31 * result + GeckoPigeonUtils.deepHash(this.toLanguages) return result } + override fun toString(): String { + return "TranslationEngineStateData(isEngineSupported=$isEngineSupported, fromLanguages=$fromLanguages, toLanguages=$toLanguages)" + } } /** @@ -1088,6 +1126,9 @@ data class TabTranslationStateData ( result = 31 * result + GeckoPigeonUtils.deepHash(this.displayError) return result } + override fun toString(): String { + return "TabTranslationStateData(tabId=$tabId, isTranslated=$isTranslated, isTranslateProcessing=$isTranslateProcessing, isOfferTranslate=$isOfferTranslate, isExpectedTranslate=$isExpectedTranslate, detectedLanguageCode=$detectedLanguageCode, userPreferredLanguageCode=$userPreferredLanguageCode, requestedFromLanguage=$requestedFromLanguage, requestedToLanguage=$requestedToLanguage, translationErrorName=$translationErrorName, displayError=$displayError)" + } } /** @@ -1169,6 +1210,9 @@ data class ReaderState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.scrollY) return result } + override fun toString(): String { + return "ReaderState(readerable=$readerable, active=$active, checkRequired=$checkRequired, connectRequired=$connectRequired, baseUrl=$baseUrl, activeUrl=$activeUrl, scrollY=$scrollY)" + } } /** @@ -1239,6 +1283,9 @@ data class AddTabParams ( result = 31 * result + GeckoPigeonUtils.deepHash(this.additionalHeaders) return result } + override fun toString(): String { + return "AddTabParams(url=$url, startLoading=$startLoading, parentId=$parentId, flags=$flags, contextId=$contextId, source=$source, private=$private, historyMetadata=$historyMetadata, additionalHeaders=$additionalHeaders)" + } } /** @@ -1303,6 +1350,9 @@ data class LastMediaAccessState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.mediaSessionActive) return result } + override fun toString(): String { + return "LastMediaAccessState(lastMediaUrl=$lastMediaUrl, lastMediaAccess=$lastMediaAccess, mediaSessionActive=$mediaSessionActive)" + } } /** @@ -1362,6 +1412,9 @@ data class HistoryMetadataKey ( result = 31 * result + GeckoPigeonUtils.deepHash(this.referrerUrl) return result } + override fun toString(): String { + return "HistoryMetadataKey(url=$url, searchTerm=$searchTerm, referrerUrl=$referrerUrl)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1396,6 +1449,9 @@ data class PackageCategoryValue ( result = 31 * result + GeckoPigeonUtils.deepHash(this.value) return result } + override fun toString(): String { + return "PackageCategoryValue(value=$value)" + } } /** @@ -1440,6 +1496,9 @@ data class ExternalPackage ( result = 31 * result + GeckoPigeonUtils.deepHash(this.category) return result } + override fun toString(): String { + return "ExternalPackage(packageId=$packageId, category=$category)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1474,6 +1533,9 @@ data class LoadUrlFlagsValue ( result = 31 * result + GeckoPigeonUtils.deepHash(this.value) return result } + override fun toString(): String { + return "LoadUrlFlagsValue(value=$value)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1512,6 +1574,9 @@ data class SourceValue ( result = 31 * result + GeckoPigeonUtils.deepHash(this.caller) return result } + override fun toString(): String { + return "SourceValue(id=$id, caller=$caller)" + } } /** @@ -1631,6 +1696,9 @@ data class TabState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.hasFormData) return result } + override fun toString(): String { + return "TabState(id=$id, url=$url, parentId=$parentId, title=$title, searchTerm=$searchTerm, contextId=$contextId, readerState=$readerState, lastAccess=$lastAccess, createdAt=$createdAt, lastMediaAccessState=$lastMediaAccessState, private=$private, historyMetadata=$historyMetadata, source=$source, index=$index, hasFormData=$hasFormData)" + } } /** @@ -1675,6 +1743,9 @@ data class RecoverableTab ( result = 31 * result + GeckoPigeonUtils.deepHash(this.state) return result } + override fun toString(): String { + return "RecoverableTab(engineSessionStateJson=$engineSessionStateJson, state=$state)" + } } /** @@ -1733,6 +1804,9 @@ data class IconRequest ( result = 31 * result + GeckoPigeonUtils.deepHash(this.waitOnNetworkLoad) return result } + override fun toString(): String { + return "IconRequest(url=$url, size=$size, resources=$resources, color=$color, isPrivate=$isPrivate, waitOnNetworkLoad=$waitOnNetworkLoad)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1771,6 +1845,9 @@ data class ResourceSize ( result = 31 * result + GeckoPigeonUtils.deepHash(this.width) return result } + override fun toString(): String { + return "ResourceSize(height=$height, width=$width)" + } } /** @@ -1825,6 +1902,9 @@ data class Resource ( result = 31 * result + GeckoPigeonUtils.deepHash(this.maskable) return result } + override fun toString(): String { + return "Resource(url=$url, type=$type, sizes=$sizes, mimeType=$mimeType, maskable=$maskable)" + } } /** @@ -1879,6 +1959,9 @@ data class IconResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.maskable) return result } + override fun toString(): String { + return "IconResult(image=${image.contentToString()}, color=$color, source=$source, maskable=$maskable)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1913,6 +1996,9 @@ data class CookiePartitionKey ( result = 31 * result + GeckoPigeonUtils.deepHash(this.topLevelSite) return result } + override fun toString(): String { + return "CookiePartitionKey(topLevelSite=$topLevelSite)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -1995,6 +2081,9 @@ data class Cookie ( result = 31 * result + GeckoPigeonUtils.deepHash(this.value) return result } + override fun toString(): String { + return "Cookie(domain=$domain, expirationDate=$expirationDate, firstPartyDomain=$firstPartyDomain, hostOnly=$hostOnly, httpOnly=$httpOnly, name=$name, partitionKey=$partitionKey, path=$path, secure=$secure, session=$session, sameSite=$sameSite, storeId=$storeId, value=$value)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2053,6 +2142,9 @@ data class VisitInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.contentId) return result } + override fun toString(): String { + return "VisitInfo(url=$url, title=$title, visitTime=$visitTime, visitType=$visitType, previewImageUrl=$previewImageUrl, isRemote=$isRemote, contentId=$contentId)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2091,6 +2183,9 @@ data class HistoryHighlightWeights ( result = 31 * result + GeckoPigeonUtils.deepHash(this.frequency) return result } + override fun toString(): String { + return "HistoryHighlightWeights(viewTime=$viewTime, frequency=$frequency)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2141,6 +2236,9 @@ data class HistoryHighlight ( result = 31 * result + GeckoPigeonUtils.deepHash(this.previewImageUrl) return result } + override fun toString(): String { + return "HistoryHighlight(score=$score, placeId=$placeId, url=$url, title=$title, previewImageUrl=$previewImageUrl)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2179,6 +2277,9 @@ data class TopFrecentSiteInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.title) return result } + override fun toString(): String { + return "TopFrecentSiteInfo(url=$url, title=$title)" + } } /** @@ -2246,6 +2347,9 @@ data class HistoryMetadata ( result = 31 * result + GeckoPigeonUtils.deepHash(this.previewImageUrl) return result } + override fun toString(): String { + return "HistoryMetadata(key=$key, title=$title, createdAt=$createdAt, updatedAt=$updatedAt, totalViewTime=$totalViewTime, documentType=$documentType, previewImageUrl=$previewImageUrl)" + } } /** @@ -2296,6 +2400,9 @@ data class HistorySuggestion ( result = 31 * result + GeckoPigeonUtils.deepHash(this.score) return result } + override fun toString(): String { + return "HistorySuggestion(url=$url, title=$title, score=$score)" + } } /** @@ -2338,6 +2445,9 @@ data class PageObservation ( result = 31 * result + GeckoPigeonUtils.deepHash(this.previewImageUrl) return result } + override fun toString(): String { + return "PageObservation(title=$title, previewImageUrl=$previewImageUrl)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2376,6 +2486,9 @@ data class HistoryItem ( result = 31 * result + GeckoPigeonUtils.deepHash(this.title) return result } + override fun toString(): String { + return "HistoryItem(url=$url, title=$title)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2422,6 +2535,9 @@ data class HistoryState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.canGoForward) return result } + override fun toString(): String { + return "HistoryState(items=$items, currentIndex=$currentIndex, canGoBack=$canGoBack, canGoForward=$canGoForward)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2465,6 +2581,9 @@ data class ReaderableState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.active) return result } + override fun toString(): String { + return "ReaderableState(readerable=$readerable, active=$active)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2507,6 +2626,9 @@ data class SecurityInfoState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.issuer) return result } + override fun toString(): String { + return "SecurityInfoState(secure=$secure, host=$host, issuer=$issuer)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2577,6 +2699,9 @@ data class TabContentState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.showToolbarAsExpanded) return result } + override fun toString(): String { + return "TabContentState(id=$id, parentId=$parentId, contextId=$contextId, url=$url, title=$title, progress=$progress, isPrivate=$isPrivate, isFullScreen=$isFullScreen, isLoading=$isLoading, showToolbarAsExpanded=$showToolbarAsExpanded)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2619,6 +2744,9 @@ data class FindResultState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.isDoneCounting) return result } + override fun toString(): String { + return "FindResultState(activeMatchOrdinal=$activeMatchOrdinal, numberOfMatches=$numberOfMatches, isDoneCounting=$isDoneCounting)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2661,6 +2789,9 @@ data class CustomSelectionAction ( result = 31 * result + GeckoPigeonUtils.deepHash(this.pattern) return result } + override fun toString(): String { + return "CustomSelectionAction(id=$id, title=$title, pattern=$pattern)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2715,6 +2846,9 @@ data class WebExtensionData ( result = 31 * result + GeckoPigeonUtils.deepHash(this.badgeBackgroundColor) return result } + override fun toString(): String { + return "WebExtensionData(extensionId=$extensionId, title=$title, enabled=$enabled, badgeText=$badgeText, badgeTextColor=$badgeTextColor, badgeBackgroundColor=$badgeBackgroundColor)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2861,6 +2995,9 @@ data class AddonInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.incognito) return result } + override fun toString(): String { + return "AddonInfo(id=$id, displayName=$displayName, summary=$summary, description=$description, downloadUrl=$downloadUrl, version=$version, installedVersion=$installedVersion, translatedPermissions=$translatedPermissions, translatedRequiredDataCollectionPermissions=$translatedRequiredDataCollectionPermissions, authorName=$authorName, authorUrl=$authorUrl, homepageUrl=$homepageUrl, detailUrl=$detailUrl, ratingUrl=$ratingUrl, ratingAverage=$ratingAverage, ratingReviews=$ratingReviews, createdAt=$createdAt, updatedAt=$updatedAt, icon=${icon?.contentToString()}, isInstalled=$isInstalled, isEnabled=$isEnabled, isSupported=$isSupported, isAllowedInPrivateBrowsing=$isAllowedInPrivateBrowsing, isAutoUpdateEnabled=$isAutoUpdateEnabled, isLocalFileInstalled=$isLocalFileInstalled, optionsPageUrl=$optionsPageUrl, openOptionsPageInTab=$openOptionsPageInTab, disabledReason=$disabledReason, incognito=$incognito)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -2903,6 +3040,9 @@ data class AddonListingPreview ( result = 31 * result + GeckoPigeonUtils.deepHash(this.caption) return result } + override fun toString(): String { + return "AddonListingPreview(imageUrl=$imageUrl, thumbnailUrl=$thumbnailUrl, caption=$caption)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3053,6 +3193,9 @@ data class AddonListing ( result = 31 * result + GeckoPigeonUtils.deepHash(this.slug) return result } + override fun toString(): String { + return "AddonListing(id=$id, name=$name, summary=$summary, description=$description, iconUrl=$iconUrl, latestVersion=$latestVersion, downloadUrl=$downloadUrl, ratingAverage=$ratingAverage, ratingReviews=$ratingReviews, authorName=$authorName, authorUrl=$authorUrl, homepageUrl=$homepageUrl, detailUrl=$detailUrl, ratingUrl=$ratingUrl, averageDailyUsers=$averageDailyUsers, promoted=$promoted, previews=$previews, permissions=$permissions, hostPermissions=$hostPermissions, optionalPermissions=$optionalPermissions, dataCollectionPermissions=$dataCollectionPermissions, fileSize=$fileSize, lastUpdated=$lastUpdated, licenseName=$licenseName, licenseUrl=$licenseUrl, supportUrl=$supportUrl, supportEmail=$supportEmail, categories=$categories, hasPrivacyPolicy=$hasPrivacyPolicy, slug=$slug)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3127,6 +3270,9 @@ data class AddonStoreInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.authorUrl) return result } + override fun toString(): String { + return "AddonStoreInfo(latestVersion=$latestVersion, latestXpiUrl=$latestXpiUrl, ratingAverage=$ratingAverage, ratingReviews=$ratingReviews, summary=$summary, description=$description, homepageUrl=$homepageUrl, detailUrl=$detailUrl, ratingUrl=$ratingUrl, authorName=$authorName, authorUrl=$authorUrl)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3173,6 +3319,9 @@ data class AddonUpdateAttemptInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.message) return result } + override fun toString(): String { + return "AddonUpdateAttemptInfo(addonId=$addonId, dateMillisecondsSinceEpoch=$dateMillisecondsSinceEpoch, status=$status, message=$message)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3231,6 +3380,9 @@ data class GeckoSuggestion ( result = 31 * result + GeckoPigeonUtils.deepHash(this.icon) return result } + override fun toString(): String { + return "GeckoSuggestion(id=$id, type=$type, score=$score, title=$title, description=$description, editSuggestion=$editSuggestion, icon=${icon?.contentToString()})" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3285,6 +3437,9 @@ data class TabContent ( result = 31 * result + GeckoPigeonUtils.deepHash(this.extractedContentPlain) return result } + override fun toString(): String { + return "TabContent(tabId=$tabId, fullContentMarkdown=$fullContentMarkdown, fullContentPlain=$fullContentPlain, isProbablyReaderable=$isProbablyReaderable, extractedContentMarkdown=$extractedContentMarkdown, extractedContentPlain=$extractedContentPlain)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3331,6 +3486,9 @@ data class ContentBlocking ( result = 31 * result + GeckoPigeonUtils.deepHash(this.bounceTrackingProtectionMode) return result } + override fun toString(): String { + return "ContentBlocking(queryParameterStripping=$queryParameterStripping, queryParameterStrippingAllowList=$queryParameterStrippingAllowList, queryParameterStrippingStripList=$queryParameterStrippingStripList, bounceTrackingProtectionMode=$bounceTrackingProtectionMode)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3377,6 +3535,9 @@ data class DohSettings ( result = 31 * result + GeckoPigeonUtils.deepHash(this.dohExceptionsList) return result } + override fun toString(): String { + return "DohSettings(dohSettingsMode=$dohSettingsMode, dohProviderUrl=$dohProviderUrl, dohDefaultProviderUrl=$dohDefaultProviderUrl, dohExceptionsList=$dohExceptionsList)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3602,6 +3763,9 @@ data class GeckoEngineSettings ( result = 31 * result + GeckoPigeonUtils.deepHash(this.lnaEnabled) return result } + override fun toString(): String { + return "GeckoEngineSettings(javascriptEnabled=$javascriptEnabled, trackingProtectionPolicy=$trackingProtectionPolicy, httpsOnlyMode=$httpsOnlyMode, globalPrivacyControlEnabled=$globalPrivacyControlEnabled, preferredColorScheme=$preferredColorScheme, cookieBannerHandlingMode=$cookieBannerHandlingMode, cookieBannerHandlingModePrivateBrowsing=$cookieBannerHandlingModePrivateBrowsing, cookieBannerHandlingGlobalRules=$cookieBannerHandlingGlobalRules, cookieBannerHandlingGlobalRulesSubFrames=$cookieBannerHandlingGlobalRulesSubFrames, webContentIsolationStrategy=$webContentIsolationStrategy, userAgent=$userAgent, contentBlocking=$contentBlocking, enterpriseRootsEnabled=$enterpriseRootsEnabled, dohSettings=$dohSettings, fingerprintingProtectionOverrides=$fingerprintingProtectionOverrides, locales=$locales, useContentBlockingDatabase=$useContentBlockingDatabase, blockCookies=$blockCookies, customCookiePolicy=$customCookiePolicy, blockTrackingContent=$blockTrackingContent, trackingContentScope=$trackingContentScope, blockCryptominers=$blockCryptominers, blockFingerprinters=$blockFingerprinters, blockRedirectTrackers=$blockRedirectTrackers, blockSuspectedFingerprinters=$blockSuspectedFingerprinters, suspectedFingerprintersScope=$suspectedFingerprintersScope, allowListBaseline=$allowListBaseline, allowListConvenience=$allowListConvenience, blockAdsAnalyticsSocialTrackers=$blockAdsAnalyticsSocialTrackers, webFontsEnabled=$webFontsEnabled, automaticFontSizeAdjustment=$automaticFontSizeAdjustment, fontSizeFactor=$fontSizeFactor, fontInflationEnabled=$fontInflationEnabled, displayDensityOverride=$displayDensityOverride, screenWidthOverride=$screenWidthOverride, screenHeightOverride=$screenHeightOverride, inputAutoZoomEnabled=$inputAutoZoomEnabled, fissionEnabled=$fissionEnabled, isolatedProcessEnabled=$isolatedProcessEnabled, appZygoteProcessEnabled=$appZygoteProcessEnabled, extensionsWebAPIEnabled=$extensionsWebAPIEnabled, lnaBlocking=$lnaBlocking, lnaBlockTrackers=$lnaBlockTrackers, lnaEnabled=$lnaEnabled)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -3652,6 +3816,9 @@ data class AutocompleteResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.totalItems) return result } + override fun toString(): String { + return "AutocompleteResult(input=$input, text=$text, url=$url, source=$source, totalItems=$totalItems)" + } } /** @@ -3702,6 +3869,9 @@ data class UnknownHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.linkText) return result } + override fun toString(): String { + return "UnknownHitResult(src=$src, linkText=$linkText)" + } } /** @@ -3744,6 +3914,9 @@ data class ImageHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.title) return result } + override fun toString(): String { + return "ImageHitResult(src=$src, title=$title)" + } } /** @@ -3786,6 +3959,9 @@ data class VideoHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.title) return result } + override fun toString(): String { + return "VideoHitResult(src=$src, title=$title)" + } } /** @@ -3828,6 +4004,9 @@ data class AudioHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.title) return result } + override fun toString(): String { + return "AudioHitResult(src=$src, title=$title)" + } } /** @@ -3870,6 +4049,9 @@ data class ImageSrcHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.uri) return result } + override fun toString(): String { + return "ImageSrcHitResult(src=$src, uri=$uri)" + } } /** @@ -3908,6 +4090,9 @@ data class PhoneHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.src) return result } + override fun toString(): String { + return "PhoneHitResult(src=$src)" + } } /** @@ -3946,6 +4131,9 @@ data class EmailHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.src) return result } + override fun toString(): String { + return "EmailHitResult(src=$src)" + } } /** @@ -3984,6 +4172,9 @@ data class GeoHitResult ( result = 31 * result + GeckoPigeonUtils.deepHash(this.src) return result } + override fun toString(): String { + return "GeoHitResult(src=$src)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4082,6 +4273,9 @@ data class DownloadState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.notificationId) return result } + override fun toString(): String { + return "DownloadState(url=$url, fileName=$fileName, contentType=$contentType, contentLength=$contentLength, currentBytesCopied=$currentBytesCopied, status=$status, userAgent=$userAgent, destinationDirectory=$destinationDirectory, directoryPath=$directoryPath, referrerUrl=$referrerUrl, skipConfirmation=$skipConfirmation, openInApp=$openInApp, id=$id, sessionId=$sessionId, private=$private, createdTime=$createdTime, notificationId=$notificationId)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4128,6 +4322,9 @@ data class ShareInternetResourceState ( result = 31 * result + GeckoPigeonUtils.deepHash(this.referrerUrl) return result } + override fun toString(): String { + return "ShareInternetResourceState(url=$url, contentType=$contentType, private=$private, referrerUrl=$referrerUrl)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4170,6 +4367,9 @@ data class AddonCollection ( result = 31 * result + GeckoPigeonUtils.deepHash(this.collectionName) return result } + override fun toString(): String { + return "AddonCollection(serverURL=$serverURL, collectionUser=$collectionUser, collectionName=$collectionName)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4208,6 +4408,9 @@ data class SyncEngineStatus ( result = 31 * result + GeckoPigeonUtils.deepHash(this.enabled) return result } + override fun toString(): String { + return "SyncEngineStatus(engine=$engine, enabled=$enabled)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4266,6 +4469,9 @@ data class SyncAccountInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.engines) return result } + override fun toString(): String { + return "SyncAccountInfo(authenticated=$authenticated, syncing=$syncing, needsReauth=$needsReauth, email=$email, displayName=$displayName, lastSyncedAt=$lastSyncedAt, engines=$engines)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4312,6 +4518,9 @@ data class SyncDevice ( result = 31 * result + GeckoPigeonUtils.deepHash(this.canSendTab) return result } + override fun toString(): String { + return "SyncDevice(deviceId=$deviceId, displayName=$displayName, isCurrentDevice=$isCurrentDevice, canSendTab=$canSendTab)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4358,6 +4567,9 @@ data class SyncIncomingTab ( result = 31 * result + GeckoPigeonUtils.deepHash(this.fromDeviceName) return result } + override fun toString(): String { + return "SyncIncomingTab(title=$title, url=$url, fromDeviceId=$fromDeviceId, fromDeviceName=$fromDeviceName)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4408,6 +4620,9 @@ data class SyncRemoteTab ( result = 31 * result + GeckoPigeonUtils.deepHash(this.inactive) return result } + override fun toString(): String { + return "SyncRemoteTab(title=$title, url=$url, iconUrl=$iconUrl, lastUsed=$lastUsed, inactive=$inactive)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4450,6 +4665,9 @@ data class SyncDeviceTabs ( result = 31 * result + GeckoPigeonUtils.deepHash(this.tabs) return result } + override fun toString(): String { + return "SyncDeviceTabs(deviceId=$deviceId, deviceName=$deviceName, tabs=$tabs)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4500,6 +4718,9 @@ data class GeckoPref ( result = 31 * result + GeckoPigeonUtils.deepHash(this.hasUserChangedValue) return result } + override fun toString(): String { + return "GeckoPref(name=$name, value=$value, defaultValue=$defaultValue, userValue=$userValue, hasUserChangedValue=$hasUserChangedValue)" + } } /** @@ -4584,6 +4805,9 @@ data class MlProgressData ( result = 31 * result + GeckoPigeonUtils.deepHash(this.id) return result } + override fun toString(): String { + return "MlProgressData(modelType=$modelType, progress=$progress, type=$type, status=$status, totalLoaded=$totalLoaded, currentLoaded=$currentLoaded, total=$total, units=$units, ok=$ok, id=$id)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4650,6 +4874,9 @@ data class GeckoProxySettings ( result = 31 * result + GeckoPigeonUtils.deepHash(this.doNotProxyLocal) return result } + override fun toString(): String { + return "GeckoProxySettings(id=$id, title=$title, type=$type, host=$host, port=$port, username=$username, password=$password, proxyDNS=$proxyDNS, doNotProxyLocal=$doNotProxyLocal)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4710,6 +4937,9 @@ data class ContainerSiteAssignment ( result = 31 * result + GeckoPigeonUtils.deepHash(this.strict) return result } + override fun toString(): String { + return "ContainerSiteAssignment(requestId=$requestId, tabId=$tabId, originUrl=$originUrl, url=$url, blocked=$blocked, strict=$strict)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4756,6 +4986,9 @@ data class ProxyLoadError ( result = 31 * result + GeckoPigeonUtils.deepHash(this.errorType) return result } + override fun toString(): String { + return "ProxyLoadError(tabId=$tabId, contextId=$contextId, url=$url, errorType=$errorType)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4794,6 +5027,9 @@ data class GeckoHeader ( result = 31 * result + GeckoPigeonUtils.deepHash(this.value) return result } + override fun toString(): String { + return "GeckoHeader(key=$key, value=$value)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4876,6 +5112,9 @@ data class GeckoFetchRequest ( result = 31 * result + GeckoPigeonUtils.deepHash(this.conservative) return result } + override fun toString(): String { + return "GeckoFetchRequest(url=$url, method=$method, headers=$headers, connectTimeoutMillis=$connectTimeoutMillis, readTimeoutMillis=$readTimeoutMillis, body=$body, redirect=$redirect, cookiePolicy=$cookiePolicy, useCaches=$useCaches, private=$private, useOhttp=$useOhttp, referrerUrl=$referrerUrl, conservative=$conservative)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4922,6 +5161,9 @@ data class GeckoFetchResponse ( result = 31 * result + GeckoPigeonUtils.deepHash(this.body) return result } + override fun toString(): String { + return "GeckoFetchResponse(url=$url, status=$status, headers=$headers, body=${body.contentToString()})" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -4988,6 +5230,9 @@ data class BookmarkNode ( result = 31 * result + GeckoPigeonUtils.deepHash(this.children) return result } + override fun toString(): String { + return "BookmarkNode(type=$type, guid=$guid, parentGuid=$parentGuid, position=$position, title=$title, url=$url, dateAdded=$dateAdded, lastModified=$lastModified, children=$children)" + } } /** @@ -5038,6 +5283,9 @@ data class BookmarkInfo ( result = 31 * result + GeckoPigeonUtils.deepHash(this.url) return result } + override fun toString(): String { + return "BookmarkInfo(parentGuid=$parentGuid, position=$position, title=$title, url=$url)" + } } /** @@ -5124,6 +5372,9 @@ data class SitePermissions ( result = 31 * result + GeckoPigeonUtils.deepHash(this.savedAt) return result } + override fun toString(): String { + return "SitePermissions(origin=$origin, camera=$camera, microphone=$microphone, location=$location, notification=$notification, persistentStorage=$persistentStorage, crossOriginStorageAccess=$crossOriginStorageAccess, mediaKeySystemAccess=$mediaKeySystemAccess, localDeviceAccess=$localDeviceAccess, localNetworkAccess=$localNetworkAccess, autoplayAudible=$autoplayAudible, autoplayInaudible=$autoplayInaudible, savedAt=$savedAt)" + } } /** @@ -5165,6 +5416,9 @@ data class TrackingProtectionException ( result = 31 * result + GeckoPigeonUtils.deepHash(this.url) return result } + override fun toString(): String { + return "TrackingProtectionException(url=$url)" + } } /** @@ -5211,6 +5465,9 @@ data class PwaIcon ( result = 31 * result + GeckoPigeonUtils.deepHash(this.type) return result } + override fun toString(): String { + return "PwaIcon(src=$src, sizes=$sizes, type=$type)" + } } /** @@ -5253,6 +5510,9 @@ data class ShareTargetFiles ( result = 31 * result + GeckoPigeonUtils.deepHash(this.accept) return result } + override fun toString(): String { + return "ShareTargetFiles(name=$name, accept=$accept)" + } } /** @@ -5303,6 +5563,9 @@ data class ShareTargetParams ( result = 31 * result + GeckoPigeonUtils.deepHash(this.files) return result } + override fun toString(): String { + return "ShareTargetParams(title=$title, text=$text, url=$url, files=$files)" + } } /** @@ -5353,6 +5616,9 @@ data class ShareTarget ( result = 31 * result + GeckoPigeonUtils.deepHash(this.params) return result } + override fun toString(): String { + return "ShareTarget(action=$action, method=$method, encType=$encType, params=$params)" + } } /** @@ -5403,6 +5669,9 @@ data class ExternalApplicationResource ( result = 31 * result + GeckoPigeonUtils.deepHash(this.minVersion) return result } + override fun toString(): String { + return "ExternalApplicationResource(platform=$platform, url=$url, id=$id, minVersion=$minVersion)" + } } /** @@ -5529,6 +5798,9 @@ data class PwaManifest ( result = 31 * result + GeckoPigeonUtils.deepHash(this.installLabel) return result } + override fun toString(): String { + return "PwaManifest(startUrl=$startUrl, name=$name, shortName=$shortName, display=$display, themeColor=$themeColor, backgroundColor=$backgroundColor, scope=$scope, description=$description, icons=$icons, dir=$dir, lang=$lang, orientation=$orientation, relatedApplications=$relatedApplications, preferRelatedApplications=$preferRelatedApplications, shareTarget=$shareTarget, currentUrl=$currentUrl, contextId=$contextId, installLabel=$installLabel)" + } } /** @@ -5594,6 +5866,9 @@ data class SandboxCaptureEntry ( result = 31 * result + GeckoPigeonUtils.deepHash(this.status) return result } + override fun toString(): String { + return "SandboxCaptureEntry(tabId=$tabId, captureId=$captureId, sourceUrl=$sourceUrl, redirectUrl=$redirectUrl, status=$status)" + } } /** @@ -5679,6 +5954,185 @@ data class GestureConfig ( result = 31 * result + GeckoPigeonUtils.deepHash(this.activeGestureKeys) return result } + override fun toString(): String { + return "GestureConfig(enabled=$enabled, strokeSize=$strokeSize, timeoutMs=$timeoutMs, maxFingers=$maxFingers, minStrokeIntervalMs=$minStrokeIntervalMs, activeGestureKeys=$activeGestureKeys)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PushDistributor ( + val packageName: String, + /** Human-readable app label, or null if the package is no longer installed. */ + val label: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): PushDistributor { + val packageName = pigeonVar_list[0] as String + val label = pigeonVar_list[1] as String? + return PushDistributor(packageName, label) + } + } + fun toList(): List { + return listOf( + packageName, + label, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as PushDistributor + return GeckoPigeonUtils.deepEquals(this.packageName, other.packageName) && GeckoPigeonUtils.deepEquals(this.label, other.label) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.packageName) + result = 31 * result + GeckoPigeonUtils.deepHash(this.label) + return result + } + override fun toString(): String { + return "PushDistributor(packageName=$packageName, label=$label)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PushStatus ( + val status: PushDistributorStatus, + val current: PushDistributor? = null, + val available: List, + /** + * Most recent distributor registration failure, or null if none. + * + * Held natively rather than delivered as a one-shot event: registrations are + * attempted at startup and from background broadcasts, both of which can run + * long before any Dart listener exists. + */ + val lastError: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): PushStatus { + val status = pigeonVar_list[0] as PushDistributorStatus + val current = pigeonVar_list[1] as PushDistributor? + val available = pigeonVar_list[2] as List + val lastError = pigeonVar_list[3] as String? + return PushStatus(status, current, available, lastError) + } + } + fun toList(): List { + return listOf( + status, + current, + available, + lastError, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as PushStatus + return GeckoPigeonUtils.deepEquals(this.status, other.status) && GeckoPigeonUtils.deepEquals(this.current, other.current) && GeckoPigeonUtils.deepEquals(this.available, other.available) && GeckoPigeonUtils.deepEquals(this.lastError, other.lastError) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.status) + result = 31 * result + GeckoPigeonUtils.deepHash(this.current) + result = 31 * result + GeckoPigeonUtils.deepHash(this.available) + result = 31 * result + GeckoPigeonUtils.deepHash(this.lastError) + return result + } + override fun toString(): String { + return "PushStatus(status=$status, current=$current, available=$available, lastError=$lastError)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PushSubscription ( + /** Subscription identifier, which for web push is the site's origin. */ + val scope: String, + /** Whether the distributor has handed back an endpoint for this scope. */ + val hasEndpoint: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): PushSubscription { + val scope = pigeonVar_list[0] as String + val hasEndpoint = pigeonVar_list[1] as Boolean + return PushSubscription(scope, hasEndpoint) + } + } + fun toList(): List { + return listOf( + scope, + hasEndpoint, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as PushSubscription + return GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.hasEndpoint, other.hasEndpoint) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.scope) + result = 31 * result + GeckoPigeonUtils.deepHash(this.hasEndpoint) + return result + } + override fun toString(): String { + return "PushSubscription(scope=$scope, hasEndpoint=$hasEndpoint)" + } +} +private data class GeckoPigeonInternalCodecOverflow ( + val type: Long, + val wrapped: Any? = null +) + { + fun toList(): List { + return listOf( + type, + wrapped, + ) + } + companion object { + fun fromList(pigeonVar_list: List): Any? { + val wrapper = GeckoPigeonInternalCodecOverflow( + type = pigeonVar_list[0] as Long, + wrapped = pigeonVar_list[1], + ); + return wrapper.unwrap() + } + } + + fun unwrap(): Any? { + if (wrapped == null) { + return null + } + + when (type.toInt()) { + 0 -> + return PushStatus.fromList(wrapped as List) + 1 -> + return PushSubscription.fromList(wrapped as List) + } + return null + } } private open class GeckoPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { @@ -5879,430 +6333,445 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { } } 168.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationOptions.fromList(it) + return (readValue(buffer) as Long?)?.let { + PushDistributorStatus.ofRaw(it.toInt()) } } 169.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationLanguage.fromList(it) + TranslationOptions.fromList(it) } } 170.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationDetectedLanguages.fromList(it) + TranslationLanguage.fromList(it) } } 171.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationPair.fromList(it) + TranslationDetectedLanguages.fromList(it) } } 172.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationEngineStateData.fromList(it) + TranslationPair.fromList(it) } } 173.toByte() -> { return (readValue(buffer) as? List)?.let { - TabTranslationStateData.fromList(it) + TranslationEngineStateData.fromList(it) } } 174.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderState.fromList(it) + TabTranslationStateData.fromList(it) } } 175.toByte() -> { return (readValue(buffer) as? List)?.let { - AddTabParams.fromList(it) + ReaderState.fromList(it) } } 176.toByte() -> { return (readValue(buffer) as? List)?.let { - LastMediaAccessState.fromList(it) + AddTabParams.fromList(it) } } 177.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadataKey.fromList(it) + LastMediaAccessState.fromList(it) } } 178.toByte() -> { return (readValue(buffer) as? List)?.let { - PackageCategoryValue.fromList(it) + HistoryMetadataKey.fromList(it) } } 179.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalPackage.fromList(it) + PackageCategoryValue.fromList(it) } } 180.toByte() -> { return (readValue(buffer) as? List)?.let { - LoadUrlFlagsValue.fromList(it) + ExternalPackage.fromList(it) } } 181.toByte() -> { return (readValue(buffer) as? List)?.let { - SourceValue.fromList(it) + LoadUrlFlagsValue.fromList(it) } } 182.toByte() -> { return (readValue(buffer) as? List)?.let { - TabState.fromList(it) + SourceValue.fromList(it) } } 183.toByte() -> { return (readValue(buffer) as? List)?.let { - RecoverableTab.fromList(it) + TabState.fromList(it) } } 184.toByte() -> { return (readValue(buffer) as? List)?.let { - IconRequest.fromList(it) + RecoverableTab.fromList(it) } } 185.toByte() -> { return (readValue(buffer) as? List)?.let { - ResourceSize.fromList(it) + IconRequest.fromList(it) } } 186.toByte() -> { return (readValue(buffer) as? List)?.let { - Resource.fromList(it) + ResourceSize.fromList(it) } } 187.toByte() -> { return (readValue(buffer) as? List)?.let { - IconResult.fromList(it) + Resource.fromList(it) } } 188.toByte() -> { return (readValue(buffer) as? List)?.let { - CookiePartitionKey.fromList(it) + IconResult.fromList(it) } } 189.toByte() -> { return (readValue(buffer) as? List)?.let { - Cookie.fromList(it) + CookiePartitionKey.fromList(it) } } 190.toByte() -> { return (readValue(buffer) as? List)?.let { - VisitInfo.fromList(it) + Cookie.fromList(it) } } 191.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlightWeights.fromList(it) + VisitInfo.fromList(it) } } 192.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlight.fromList(it) + HistoryHighlightWeights.fromList(it) } } 193.toByte() -> { return (readValue(buffer) as? List)?.let { - TopFrecentSiteInfo.fromList(it) + HistoryHighlight.fromList(it) } } 194.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadata.fromList(it) + TopFrecentSiteInfo.fromList(it) } } 195.toByte() -> { return (readValue(buffer) as? List)?.let { - HistorySuggestion.fromList(it) + HistoryMetadata.fromList(it) } } 196.toByte() -> { return (readValue(buffer) as? List)?.let { - PageObservation.fromList(it) + HistorySuggestion.fromList(it) } } 197.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryItem.fromList(it) + PageObservation.fromList(it) } } 198.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryState.fromList(it) + HistoryItem.fromList(it) } } 199.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderableState.fromList(it) + HistoryState.fromList(it) } } 200.toByte() -> { return (readValue(buffer) as? List)?.let { - SecurityInfoState.fromList(it) + ReaderableState.fromList(it) } } 201.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContentState.fromList(it) + SecurityInfoState.fromList(it) } } 202.toByte() -> { return (readValue(buffer) as? List)?.let { - FindResultState.fromList(it) + TabContentState.fromList(it) } } 203.toByte() -> { return (readValue(buffer) as? List)?.let { - CustomSelectionAction.fromList(it) + FindResultState.fromList(it) } } 204.toByte() -> { return (readValue(buffer) as? List)?.let { - WebExtensionData.fromList(it) + CustomSelectionAction.fromList(it) } } 205.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonInfo.fromList(it) + WebExtensionData.fromList(it) } } 206.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListingPreview.fromList(it) + AddonInfo.fromList(it) } } 207.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListing.fromList(it) + AddonListingPreview.fromList(it) } } 208.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonStoreInfo.fromList(it) + AddonListing.fromList(it) } } 209.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonUpdateAttemptInfo.fromList(it) + AddonStoreInfo.fromList(it) } } 210.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoSuggestion.fromList(it) + AddonUpdateAttemptInfo.fromList(it) } } 211.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContent.fromList(it) + GeckoSuggestion.fromList(it) } } 212.toByte() -> { return (readValue(buffer) as? List)?.let { - ContentBlocking.fromList(it) + TabContent.fromList(it) } } 213.toByte() -> { return (readValue(buffer) as? List)?.let { - DohSettings.fromList(it) + ContentBlocking.fromList(it) } } 214.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoEngineSettings.fromList(it) + DohSettings.fromList(it) } } 215.toByte() -> { return (readValue(buffer) as? List)?.let { - AutocompleteResult.fromList(it) + GeckoEngineSettings.fromList(it) } } 216.toByte() -> { return (readValue(buffer) as? List)?.let { - UnknownHitResult.fromList(it) + AutocompleteResult.fromList(it) } } 217.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageHitResult.fromList(it) + UnknownHitResult.fromList(it) } } 218.toByte() -> { return (readValue(buffer) as? List)?.let { - VideoHitResult.fromList(it) + ImageHitResult.fromList(it) } } 219.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioHitResult.fromList(it) + VideoHitResult.fromList(it) } } 220.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageSrcHitResult.fromList(it) + AudioHitResult.fromList(it) } } 221.toByte() -> { return (readValue(buffer) as? List)?.let { - PhoneHitResult.fromList(it) + ImageSrcHitResult.fromList(it) } } 222.toByte() -> { return (readValue(buffer) as? List)?.let { - EmailHitResult.fromList(it) + PhoneHitResult.fromList(it) } } 223.toByte() -> { return (readValue(buffer) as? List)?.let { - GeoHitResult.fromList(it) + EmailHitResult.fromList(it) } } 224.toByte() -> { return (readValue(buffer) as? List)?.let { - DownloadState.fromList(it) + GeoHitResult.fromList(it) } } 225.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareInternetResourceState.fromList(it) + DownloadState.fromList(it) } } 226.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonCollection.fromList(it) + ShareInternetResourceState.fromList(it) } } 227.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncEngineStatus.fromList(it) + AddonCollection.fromList(it) } } 228.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncAccountInfo.fromList(it) + SyncEngineStatus.fromList(it) } } 229.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDevice.fromList(it) + SyncAccountInfo.fromList(it) } } 230.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncIncomingTab.fromList(it) + SyncDevice.fromList(it) } } 231.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncRemoteTab.fromList(it) + SyncIncomingTab.fromList(it) } } 232.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDeviceTabs.fromList(it) + SyncRemoteTab.fromList(it) } } 233.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoPref.fromList(it) + SyncDeviceTabs.fromList(it) } } 234.toByte() -> { return (readValue(buffer) as? List)?.let { - MlProgressData.fromList(it) + GeckoPref.fromList(it) } } 235.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoProxySettings.fromList(it) + MlProgressData.fromList(it) } } 236.toByte() -> { return (readValue(buffer) as? List)?.let { - ContainerSiteAssignment.fromList(it) + GeckoProxySettings.fromList(it) } } 237.toByte() -> { return (readValue(buffer) as? List)?.let { - ProxyLoadError.fromList(it) + ContainerSiteAssignment.fromList(it) } } 238.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoHeader.fromList(it) + ProxyLoadError.fromList(it) } } 239.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchRequest.fromList(it) + GeckoHeader.fromList(it) } } 240.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchResponse.fromList(it) + GeckoFetchRequest.fromList(it) } } 241.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkNode.fromList(it) + GeckoFetchResponse.fromList(it) } } 242.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkInfo.fromList(it) + BookmarkNode.fromList(it) } } 243.toByte() -> { return (readValue(buffer) as? List)?.let { - SitePermissions.fromList(it) + BookmarkInfo.fromList(it) } } 244.toByte() -> { return (readValue(buffer) as? List)?.let { - TrackingProtectionException.fromList(it) + SitePermissions.fromList(it) } } 245.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaIcon.fromList(it) + TrackingProtectionException.fromList(it) } } 246.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetFiles.fromList(it) + PwaIcon.fromList(it) } } 247.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetParams.fromList(it) + ShareTargetFiles.fromList(it) } } 248.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTarget.fromList(it) + ShareTargetParams.fromList(it) } } 249.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalApplicationResource.fromList(it) + ShareTarget.fromList(it) } } 250.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaManifest.fromList(it) + ExternalApplicationResource.fromList(it) } } 251.toByte() -> { return (readValue(buffer) as? List)?.let { - SandboxCaptureEntry.fromList(it) + PwaManifest.fromList(it) } } 252.toByte() -> { + return (readValue(buffer) as? List)?.let { + SandboxCaptureEntry.fromList(it) + } + } + 253.toByte() -> { return (readValue(buffer) as? List)?.let { GestureConfig.fromList(it) } } + 254.toByte() -> { + return (readValue(buffer) as? List)?.let { + PushDistributor.fromList(it) + } + } + 255.toByte() -> { + return (readValue(buffer) as? List)?.let { + GeckoPigeonInternalCodecOverflow.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -6464,346 +6933,364 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(167) writeValue(stream, value.raw.toLong()) } - is TranslationOptions -> { + is PushDistributorStatus -> { stream.write(168) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationLanguage -> { + is TranslationOptions -> { stream.write(169) writeValue(stream, value.toList()) } - is TranslationDetectedLanguages -> { + is TranslationLanguage -> { stream.write(170) writeValue(stream, value.toList()) } - is TranslationPair -> { + is TranslationDetectedLanguages -> { stream.write(171) writeValue(stream, value.toList()) } - is TranslationEngineStateData -> { + is TranslationPair -> { stream.write(172) writeValue(stream, value.toList()) } - is TabTranslationStateData -> { + is TranslationEngineStateData -> { stream.write(173) writeValue(stream, value.toList()) } - is ReaderState -> { + is TabTranslationStateData -> { stream.write(174) writeValue(stream, value.toList()) } - is AddTabParams -> { + is ReaderState -> { stream.write(175) writeValue(stream, value.toList()) } - is LastMediaAccessState -> { + is AddTabParams -> { stream.write(176) writeValue(stream, value.toList()) } - is HistoryMetadataKey -> { + is LastMediaAccessState -> { stream.write(177) writeValue(stream, value.toList()) } - is PackageCategoryValue -> { + is HistoryMetadataKey -> { stream.write(178) writeValue(stream, value.toList()) } - is ExternalPackage -> { + is PackageCategoryValue -> { stream.write(179) writeValue(stream, value.toList()) } - is LoadUrlFlagsValue -> { + is ExternalPackage -> { stream.write(180) writeValue(stream, value.toList()) } - is SourceValue -> { + is LoadUrlFlagsValue -> { stream.write(181) writeValue(stream, value.toList()) } - is TabState -> { + is SourceValue -> { stream.write(182) writeValue(stream, value.toList()) } - is RecoverableTab -> { + is TabState -> { stream.write(183) writeValue(stream, value.toList()) } - is IconRequest -> { + is RecoverableTab -> { stream.write(184) writeValue(stream, value.toList()) } - is ResourceSize -> { + is IconRequest -> { stream.write(185) writeValue(stream, value.toList()) } - is Resource -> { + is ResourceSize -> { stream.write(186) writeValue(stream, value.toList()) } - is IconResult -> { + is Resource -> { stream.write(187) writeValue(stream, value.toList()) } - is CookiePartitionKey -> { + is IconResult -> { stream.write(188) writeValue(stream, value.toList()) } - is Cookie -> { + is CookiePartitionKey -> { stream.write(189) writeValue(stream, value.toList()) } - is VisitInfo -> { + is Cookie -> { stream.write(190) writeValue(stream, value.toList()) } - is HistoryHighlightWeights -> { + is VisitInfo -> { stream.write(191) writeValue(stream, value.toList()) } - is HistoryHighlight -> { + is HistoryHighlightWeights -> { stream.write(192) writeValue(stream, value.toList()) } - is TopFrecentSiteInfo -> { + is HistoryHighlight -> { stream.write(193) writeValue(stream, value.toList()) } - is HistoryMetadata -> { + is TopFrecentSiteInfo -> { stream.write(194) writeValue(stream, value.toList()) } - is HistorySuggestion -> { + is HistoryMetadata -> { stream.write(195) writeValue(stream, value.toList()) } - is PageObservation -> { + is HistorySuggestion -> { stream.write(196) writeValue(stream, value.toList()) } - is HistoryItem -> { + is PageObservation -> { stream.write(197) writeValue(stream, value.toList()) } - is HistoryState -> { + is HistoryItem -> { stream.write(198) writeValue(stream, value.toList()) } - is ReaderableState -> { + is HistoryState -> { stream.write(199) writeValue(stream, value.toList()) } - is SecurityInfoState -> { + is ReaderableState -> { stream.write(200) writeValue(stream, value.toList()) } - is TabContentState -> { + is SecurityInfoState -> { stream.write(201) writeValue(stream, value.toList()) } - is FindResultState -> { + is TabContentState -> { stream.write(202) writeValue(stream, value.toList()) } - is CustomSelectionAction -> { + is FindResultState -> { stream.write(203) writeValue(stream, value.toList()) } - is WebExtensionData -> { + is CustomSelectionAction -> { stream.write(204) writeValue(stream, value.toList()) } - is AddonInfo -> { + is WebExtensionData -> { stream.write(205) writeValue(stream, value.toList()) } - is AddonListingPreview -> { + is AddonInfo -> { stream.write(206) writeValue(stream, value.toList()) } - is AddonListing -> { + is AddonListingPreview -> { stream.write(207) writeValue(stream, value.toList()) } - is AddonStoreInfo -> { + is AddonListing -> { stream.write(208) writeValue(stream, value.toList()) } - is AddonUpdateAttemptInfo -> { + is AddonStoreInfo -> { stream.write(209) writeValue(stream, value.toList()) } - is GeckoSuggestion -> { + is AddonUpdateAttemptInfo -> { stream.write(210) writeValue(stream, value.toList()) } - is TabContent -> { + is GeckoSuggestion -> { stream.write(211) writeValue(stream, value.toList()) } - is ContentBlocking -> { + is TabContent -> { stream.write(212) writeValue(stream, value.toList()) } - is DohSettings -> { + is ContentBlocking -> { stream.write(213) writeValue(stream, value.toList()) } - is GeckoEngineSettings -> { + is DohSettings -> { stream.write(214) writeValue(stream, value.toList()) } - is AutocompleteResult -> { + is GeckoEngineSettings -> { stream.write(215) writeValue(stream, value.toList()) } - is UnknownHitResult -> { + is AutocompleteResult -> { stream.write(216) writeValue(stream, value.toList()) } - is ImageHitResult -> { + is UnknownHitResult -> { stream.write(217) writeValue(stream, value.toList()) } - is VideoHitResult -> { + is ImageHitResult -> { stream.write(218) writeValue(stream, value.toList()) } - is AudioHitResult -> { + is VideoHitResult -> { stream.write(219) writeValue(stream, value.toList()) } - is ImageSrcHitResult -> { + is AudioHitResult -> { stream.write(220) writeValue(stream, value.toList()) } - is PhoneHitResult -> { + is ImageSrcHitResult -> { stream.write(221) writeValue(stream, value.toList()) } - is EmailHitResult -> { + is PhoneHitResult -> { stream.write(222) writeValue(stream, value.toList()) } - is GeoHitResult -> { + is EmailHitResult -> { stream.write(223) writeValue(stream, value.toList()) } - is DownloadState -> { + is GeoHitResult -> { stream.write(224) writeValue(stream, value.toList()) } - is ShareInternetResourceState -> { + is DownloadState -> { stream.write(225) writeValue(stream, value.toList()) } - is AddonCollection -> { + is ShareInternetResourceState -> { stream.write(226) writeValue(stream, value.toList()) } - is SyncEngineStatus -> { + is AddonCollection -> { stream.write(227) writeValue(stream, value.toList()) } - is SyncAccountInfo -> { + is SyncEngineStatus -> { stream.write(228) writeValue(stream, value.toList()) } - is SyncDevice -> { + is SyncAccountInfo -> { stream.write(229) writeValue(stream, value.toList()) } - is SyncIncomingTab -> { + is SyncDevice -> { stream.write(230) writeValue(stream, value.toList()) } - is SyncRemoteTab -> { + is SyncIncomingTab -> { stream.write(231) writeValue(stream, value.toList()) } - is SyncDeviceTabs -> { + is SyncRemoteTab -> { stream.write(232) writeValue(stream, value.toList()) } - is GeckoPref -> { + is SyncDeviceTabs -> { stream.write(233) writeValue(stream, value.toList()) } - is MlProgressData -> { + is GeckoPref -> { stream.write(234) writeValue(stream, value.toList()) } - is GeckoProxySettings -> { + is MlProgressData -> { stream.write(235) writeValue(stream, value.toList()) } - is ContainerSiteAssignment -> { + is GeckoProxySettings -> { stream.write(236) writeValue(stream, value.toList()) } - is ProxyLoadError -> { + is ContainerSiteAssignment -> { stream.write(237) writeValue(stream, value.toList()) } - is GeckoHeader -> { + is ProxyLoadError -> { stream.write(238) writeValue(stream, value.toList()) } - is GeckoFetchRequest -> { + is GeckoHeader -> { stream.write(239) writeValue(stream, value.toList()) } - is GeckoFetchResponse -> { + is GeckoFetchRequest -> { stream.write(240) writeValue(stream, value.toList()) } - is BookmarkNode -> { + is GeckoFetchResponse -> { stream.write(241) writeValue(stream, value.toList()) } - is BookmarkInfo -> { + is BookmarkNode -> { stream.write(242) writeValue(stream, value.toList()) } - is SitePermissions -> { + is BookmarkInfo -> { stream.write(243) writeValue(stream, value.toList()) } - is TrackingProtectionException -> { + is SitePermissions -> { stream.write(244) writeValue(stream, value.toList()) } - is PwaIcon -> { + is TrackingProtectionException -> { stream.write(245) writeValue(stream, value.toList()) } - is ShareTargetFiles -> { + is PwaIcon -> { stream.write(246) writeValue(stream, value.toList()) } - is ShareTargetParams -> { + is ShareTargetFiles -> { stream.write(247) writeValue(stream, value.toList()) } - is ShareTarget -> { + is ShareTargetParams -> { stream.write(248) writeValue(stream, value.toList()) } - is ExternalApplicationResource -> { + is ShareTarget -> { stream.write(249) writeValue(stream, value.toList()) } - is PwaManifest -> { + is ExternalApplicationResource -> { stream.write(250) writeValue(stream, value.toList()) } - is SandboxCaptureEntry -> { + is PwaManifest -> { stream.write(251) writeValue(stream, value.toList()) } - is GestureConfig -> { + is SandboxCaptureEntry -> { stream.write(252) writeValue(stream, value.toList()) } + is GestureConfig -> { + stream.write(253) + writeValue(stream, value.toList()) + } + is PushDistributor -> { + stream.write(254) + writeValue(stream, value.toList()) + } + is PushStatus -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 0, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is PushSubscription -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 1, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } else -> super.writeValue(stream, value) } } @@ -6819,7 +7306,6 @@ interface GeckoBrowserApi { fun openInCustomTab(url: String, private: Boolean, contextId: String?) fun isDefaultBrowser(): Boolean fun requestDefaultBrowser() - fun pickUnifiedPushDistributor(callback: (Result) -> Unit) fun shutdown() companion object { @@ -6956,24 +7442,6 @@ interface GeckoBrowserApi { channel.setMessageHandler(null) } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.pickUnifiedPushDistributor$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - api.pickUnifiedPushDistributor{ 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.GeckoBrowserApi.shutdown$separatedMessageChannelSuffix", codec) if (api != null) { @@ -12237,3 +12705,188 @@ class GeckoGestureEvents(private val binaryMessenger: BinaryMessenger, private v } } } +/** + * Dart → Kotlin. UnifiedPush distributor management and web push introspection. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface GeckoPushApi { + fun getPushStatus(callback: (Result) -> Unit) + /** + * Selects [packageName], which must be one of [PushStatus.available]. + * + * The picker is built in Dart rather than delegated to the connector's own + * dialog, which would save the selection against a non-profile context. + */ + fun setDistributor(packageName: String, callback: (Result) -> Unit) + /** Forgets the current distributor. This is the off switch for web push. */ + fun removeDistributor(callback: (Result) -> Unit) + fun renewRegistration(callback: (Result) -> Unit) + /** + * Pauses push transport for the current profile before switching profiles. + * Site subscriptions and the chosen distributor are retained for restoration + * when this profile becomes active again. + */ + fun suspendForProfileSwitch(targetProfileId: String, callback: (Result) -> Unit) + /** + * Subscriptions Gecko has created, read from the UnifiedPush store. Read-only: + * there is no app→Gecko channel to revoke a subscription, so removal has to go + * through the site's notification permission instead. + */ + fun getSubscriptions(callback: (Result>) -> Unit) + + companion object { + /** The codec used by GeckoPushApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoPushApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoPushApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.getPushStatus$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getPushStatus{ 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.GeckoPushApi.setDistributor$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val packageNameArg = args[0] as String + api.setDistributor(packageNameArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.removeDistributor$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.removeDistributor{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.renewRegistration$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.renewRegistration{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.suspendForProfileSwitch$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val targetProfileIdArg = args[0] as String + api.suspendForProfileSwitch(targetProfileIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.getSubscriptions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getSubscriptions{ 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) + } + } + } + } +} +/** + * Kotlin → Dart. Push registration lifecycle. + * + * Registration failures reach Dart through [PushStatus.lastError] rather than a + * dedicated event, so a failure raised before any Dart listener is attached is + * still visible the first time the settings screen reads the status. + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class GeckoPushEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoPushEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + /** [sequence] Event sequence number for ordering. */ + fun onPushStatusChanged(sequenceArg: Long, statusArg: PushStatus, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoPushEvents.onPushStatusChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, statusArg)) { + 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))) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/Push.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/Push.kt new file mode 100644 index 00000000..603ed554 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/Push.kt @@ -0,0 +1,473 @@ +/* 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.push + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ActiveProfile +import eu.weblibre.flutter_mozilla_components.ext.EventSequence +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext +import org.ironfoxoss.unifiedpush.PushError +import org.ironfoxoss.unifiedpush.SubscriptionsDB +import org.ironfoxoss.unifiedpush.UnifiedPushFeature +import org.ironfoxoss.unifiedpush.UnifiedPushNotification +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import org.mozilla.gecko.GeckoThread +import mozilla.components.support.ktx.kotlin.getOrigin + +private const val START_WAITING = 0 +private const val STARTED = 1 +private const val START_TIMED_OUT = 2 + +/** + * Bounds only the wait for [operation] to call its start gate. Once started, + * the operation is allowed to finish so a completed side effect cannot be + * reported as a timeout. + */ +internal suspend fun runWithStartTimeout( + timeoutMillis: Long, + operation: suspend (tryStart: () -> Boolean) -> Unit, +): Boolean = coroutineScope { + require(timeoutMillis > 0) { "Timeout must be positive" } + + val state = AtomicInteger(START_WAITING) + val started = CompletableDeferred() + val operationJob = async { + operation { + if (!state.compareAndSet(START_WAITING, STARTED)) { + false + } else { + started.complete(Unit) + true + } + } + } + + val startedBeforeTimeout = withTimeoutOrNull(timeoutMillis) { + started.await() + true + } == true + + if (!startedBeforeTimeout && state.compareAndSet(START_WAITING, START_TIMED_OUT)) { + operationJob.cancelAndJoin() + false + } else { + operationJob.await() + true + } +} + +/** Lifecycle state of the selected UnifiedPush distributor. */ +enum class DistributorStatus { + NONE_AVAILABLE, + NOT_SELECTED, + PENDING, + READY, + UNAVAILABLE, +} + +data class DistributorInfo(val packageName: String, val label: String?) + +data class PushStatusSnapshot( + val status: DistributorStatus, + val current: DistributorInfo?, + val available: List, + val lastError: String?, +) + +data class PushSubscriptionInfo(val scope: String, val hasEndpoint: Boolean) + +/** Profile-scoped UnifiedPush state and Gecko web-push integration. */ +class Push( + private val components: Components, +) : AutoCloseable { + private val initialized = AtomicBoolean(false) + private val closed = AtomicBoolean(false) + internal val isClosed: Boolean + get() = closed.get() + private val dispatcher: ExecutorCoroutineDispatcher = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "WebLibrePush-${components.profileApplicationContext.relativePath.hashCode()}") + }.asCoroutineDispatcher() + private val eventScope = CoroutineScope(dispatcher + SupervisorJob()) + + private val context: Context + get() = components.profileApplicationContext + + private val prefs = PushProfileState.prefs(context) + private val subscriptionsDb = SubscriptionsDB(context) + + val feature = UnifiedPushFeature( + context = context, + coroutineContext = dispatcher, + db = subscriptionsDb, + ) + + private val webPushEngineIntegration = + WebPushEngineIntegration(components.core.engine, feature) + + fun initialize() { + check(!closed.get()) { "Push is closed" } + if (!initialized.compareAndSet(false, true)) return + + restoreRememberedDistributor() + components.core.store + webPushEngineIntegration.start() + feature.initialize() + eventScope.launch { + while (!PushMessageScheduler.recover(components.profileApplicationContext)) { + delay(RECOVERY_RETRY_DELAY_MS) + } + notifyIfDistributorMissing() + } + } + + fun status(): PushStatusSnapshot { + val available = UnifiedPush.getDistributors(context).map { it.toDistributorInfo() } + val acknowledged = UnifiedPush.getAckDistributor(context) + val saved = UnifiedPush.getSavedDistributor(context) + val remembered = rememberedDistributor() + val status = when { + acknowledged != null -> DistributorStatus.READY + saved != null -> DistributorStatus.PENDING + remembered != null && available.none { it.packageName == remembered } -> + DistributorStatus.UNAVAILABLE + available.isEmpty() -> DistributorStatus.NONE_AVAILABLE + else -> DistributorStatus.NOT_SELECTED + } + + return PushStatusSnapshot( + status = status, + current = (acknowledged ?: saved ?: remembered)?.toDistributorInfo(), + available = available, + lastError = PushProfileState.lastError(context), + ) + } + + suspend fun setDistributor(packageName: String) = UnifiedPushReceiver.runExclusive { + withContext(dispatcher) { + check(!closed.get()) { "Push is closed" } + require(UnifiedPush.getDistributors(context).contains(packageName)) { + "UnifiedPush distributor is not installed: $packageName" + } + + val current = UnifiedPush.getSavedDistributor(context) ?: rememberedDistributor() + if (current != null && current != packageName) { + removeTransportRegistrationsAndEndpoints() + } + UnifiedPush.saveDistributor(context, packageName) + rememberDistributor(packageName) + PushProfileState.clearError(context) + cancelMissingDistributorNotification() + feature.renewRegistration() + } + } + + suspend fun removeDistributor() = UnifiedPushReceiver.runExclusive { + withContext(dispatcher) { + check(!closed.get()) { "Push is closed" } + removeTransportRegistrationsAndEndpoints() + prefs.edit().remove(PushProfileState.KEY_SELECTED_DISTRIBUTOR).commit() + PushProfileState.clearError(context) + cancelMissingDistributorNotification() + } + } + + suspend fun renewRegistration() = UnifiedPushReceiver.runExclusive { + withContext(dispatcher) { + check(!closed.get()) { "Push is closed" } + restoreRememberedDistributor() + feature.renewRegistration() + } + } + + suspend fun subscriptions(): List = withContext(dispatcher) { + subscriptionsDb.listSubscriptions().map { + PushSubscriptionInfo(scope = it.scope, hasEndpoint = it.endpoint != null) + } + } + + suspend fun onNewEndpoint(scope: String, endpoint: PushEndpoint) = withContext(dispatcher) { + feature.onNewEndpoint(scope, endpoint) + if (endpoint.pubKeySet != null) PushProfileState.clearError(context, scope) + } + + suspend fun invalidateEndpoint(scope: String) = withContext(dispatcher) { + subscriptionsDb.removeEndpoint(scope) + PushProfileState.clearError(context, scope) + webPushEngineIntegration.invalidateEndpoint(scope) + } + + suspend fun onUnregistered(scope: String) = invalidateEndpoint(scope) + + suspend fun recordRegistrationError(scope: String, error: PushError) = withContext(dispatcher) { + PushProfileState.recordError( + context, + scope, + PushProfileState.errorType(error), + error.message, + ) + } + + suspend fun recordTemporaryUnavailable(scope: String) = withContext(dispatcher) { + PushProfileState.recordTemporaryUnavailable(context, scope) + } + + suspend fun deliverMessage(scope: String, payload: ByteArray) { + val external = GlobalComponents.isExternalMode + val deliver: suspend () -> Unit = { + withContext(Dispatchers.Main.immediate) { + check(!closed.get()) { "Push is closed" } + check(GeckoThread.isStateAtLeast(GeckoThread.State.RUNNING)) { + "Gecko is not running" + } + webPushEngineIntegration.deliverMessage(scope, payload) + } + } + + if (!external) { + deliver() + return + } + + // Headless: the push message is decrypted and permitted, but GeckoView + // will not run the service worker's push handler without a live browsing + // context — with no open session the ServiceWorkerManager never dispatches + // the event (opening a tab is what makes it fire). Create a throwaway + // session for the duration of delivery so Gecko has a window to run the + // worker in, then tear it down. + val origin = runCatching { scope.getOrigin() }.getOrNull() + val session = withContext(Dispatchers.Main.immediate) { + components.core.engine.createSession().also { it.loadUrl("about:blank") } + } + try { + // Give the browsing context time to come up before handing off the push. + delay(HEADLESS_SESSION_WARMUP_MS) + // The push handoff returns no completion signal, so keep this delivery + // alive until the service worker actually posts its notification, + // bounded by a timeout. + components.core.webNotificationDrainCoordinator.drainWhileDelivering( + origin = origin, + timeoutMillis = HEADLESS_DELIVERY_DRAIN_TIMEOUT_MS, + graceMillis = HEADLESS_DELIVERY_POST_GRACE_MS, + deliver = deliver, + ) + } finally { + withContext(Dispatchers.Main.immediate) { session.close() } + } + } + + /** + * Persist the profile switch while holding the profile lock, so an in-flight + * delivery worker (which holds the same lock for the duration of a delivery) + * cannot straddle the switch and deliver this profile's message after disk + * state has moved on. Throws on failure so the caller can abort rather than + * proceed with an inconsistent on-disk profile. + */ + suspend fun persistProfileSwitch(targetProfileId: String) { + check(!closed.get()) { "Push is closed" } + persistProfileSwitch(targetProfileId) { true } + } + + /** + * Persist the switch if profile and receiver exclusivity can be obtained + * within [startTimeoutMillis]. The timeout stops applying once the atomic + * file write starts. + */ + suspend fun persistProfileSwitch( + targetProfileId: String, + startTimeoutMillis: Long, + ): Boolean { + check(!closed.get()) { "Push is closed" } + return runWithStartTimeout(startTimeoutMillis) { tryStart -> + persistProfileSwitch(targetProfileId, tryStart) + } + } + + private suspend fun persistProfileSwitch( + targetProfileId: String, + tryStart: () -> Boolean, + ) { + ActiveProfile.withProfileLock { + UnifiedPushReceiver.runExclusive { + if (tryStart()) { + ActiveProfile.switchTo( + components.profileApplicationContext.rootApplicationContext, + targetProfileId, + ) + } + } + } + } + + /** + * Detach the now-inactive profile's push transport. Best-effort: a stale + * registration is harmless and is cleaned up when that profile next becomes + * active. Subscriptions and the remembered distributor are preserved. + */ + suspend fun detachTransportForSwitch() { + ActiveProfile.withProfileLock { + UnifiedPushReceiver.runExclusive { + withContext(dispatcher) { + if (!closed.get()) { + removeTransportRegistrationsAndEndpoints(notifyGecko = false) + } + } + } + } + } + + /** Persist the switch (mandatory), then best-effort detach the old transport. */ + suspend fun suspendForProfileSwitch(targetProfileId: String) { + persistProfileSwitch(targetProfileId) + runCatching { detachTransportForSwitch() } + .onFailure { error -> + Log.w(TAG, "Failed to detach push transport during profile switch", error) + } + } + + fun emitStatusChanged() { + if (closed.get() || GlobalComponents.pushEvents == null) return + eventScope.launch { + val snapshot = runCatching { status().toPigeon() }.getOrNull() ?: return@launch + val sequence = EventSequence.next() + withContext(kotlinx.coroutines.Dispatchers.Main) { + GlobalComponents.pushEvents?.onPushStatusChanged(sequence, snapshot) { } + } + } + } + + override fun close() { + if (!beginClose()) return + try { + runBlocking { finishClose() } + } finally { + dispatcher.close() + } + } + + /** Mark closed immediately and drain profile resources without blocking the caller. */ + internal fun closeDeferred() { + if (!beginClose()) return + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + finishClose() + } catch (error: Throwable) { + Log.w(TAG, "Failed to finish deferred push cleanup", error) + } finally { + dispatcher.close() + } + } + } + + private fun beginClose(): Boolean { + if (!closed.compareAndSet(false, true)) return false + eventScope.cancel() + if (initialized.get()) { + webPushEngineIntegration.close() + } + return true + } + + private suspend fun finishClose() { + if (initialized.get()) { + val drained = CompletableDeferred() + feature.withCoroutine { drained.complete(Unit) } + withTimeoutOrNull(FEATURE_DRAIN_TIMEOUT_MS) { drained.await() } + } + withContext(dispatcher) { + subscriptionsDb.close() + } + } + + private fun restoreRememberedDistributor() { + if (UnifiedPush.getSavedDistributor(context) != null) return + val remembered = rememberedDistributor() ?: return + if (UnifiedPush.getDistributors(context).contains(remembered)) { + UnifiedPush.saveDistributor(context, remembered) + } + } + + private suspend fun removeTransportRegistrationsAndEndpoints(notifyGecko: Boolean = true) { + UnifiedPush.removeDistributor(context) + subscriptionsDb.listSubscriptions().forEach { + subscriptionsDb.removeEndpoint(it.scope) + if (notifyGecko) webPushEngineIntegration.invalidateEndpoint(it.scope) + } + } + + private fun rememberedDistributor(): String? = + prefs.getString(PushProfileState.KEY_SELECTED_DISTRIBUTOR, null) + + private fun rememberDistributor(packageName: String) { + prefs.edit().putString(PushProfileState.KEY_SELECTED_DISTRIBUTOR, packageName).commit() + } + + private fun notifyIfDistributorMissing() { + if (status().status != DistributorStatus.UNAVAILABLE) return + val notificationManager = NotificationManagerCompat.from(context) + if (!notificationManager.areNotificationsEnabled()) return + try { + notificationManager.notify( + UnifiedPushNotification.getNotificationId(context), + UnifiedPushNotification.createMissingServiceNotification(context), + ) + } catch (_: SecurityException) { + // The settings status remains available when POST_NOTIFICATIONS is denied. + } + } + + private fun cancelMissingDistributorNotification() { + NotificationManagerCompat.from(context) + .cancel(UnifiedPushNotification.getNotificationId(context)) + } + + private fun String.toDistributorInfo(): DistributorInfo = + DistributorInfo(packageName = this, label = resolveLabel(this)) + + private fun resolveLabel(packageName: String): String? = try { + val packageManager = context.packageManager + packageManager.getApplicationInfo(packageName, 0).loadLabel(packageManager).toString() + } catch (_: PackageManager.NameNotFoundException) { + null + } + + companion object { + private const val TAG = "Push" + private const val FEATURE_DRAIN_TIMEOUT_MS = 5000L + // Upper bound on how long a headless delivery keeps the worker alive + // waiting for the service worker to post its notification. + private const val HEADLESS_DELIVERY_DRAIN_TIMEOUT_MS = 25000L + // Extra time after onShowNotification fires so the delegate's async + // notify can land before the process loses foreground priority. + private const val HEADLESS_DELIVERY_POST_GRACE_MS = 1500L + // Time for the throwaway delivery session's browsing context to come up + // before the push is handed off. + private const val HEADLESS_SESSION_WARMUP_MS = 1500L + private const val RECOVERY_RETRY_DELAY_MS = 30000L + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageScheduler.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageScheduler.kt new file mode 100644 index 00000000..f917a12c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageScheduler.kt @@ -0,0 +1,96 @@ +/* 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.push + +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import eu.weblibre.flutter_mozilla_components.ProfileContext +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import mozilla.components.support.ktx.android.content.runOnlyInMainProcess + +object PushMessageScheduler { + fun enqueue(context: ProfileContext, messageId: String) { + val request = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + MIN_BACKOFF_SECONDS, + TimeUnit.SECONDS, + ) + .setInputData( + Data.Builder() + .putString(PushMessageWorker.KEY_PROFILE_PATH, context.relativePath) + .putString(PushMessageWorker.KEY_MESSAGE_ID, messageId) + .build(), + ) + .build() + val operation = WorkManager.getInstance(context) + .enqueueUniqueWork(workName(context.relativePath, messageId), ExistingWorkPolicy.KEEP, request) + operation.result.addListener( + { + runCatching { operation.result.get() }.onFailure { error -> + Log.e(TAG, "Unable to enqueue push message $messageId", error) + recoverLater(context) + } + }, + recoveryExecutor, + ) + } + + fun recover(context: ProfileContext): Boolean { + val store = PushMessageStore(context) + return store.ids().map { messageId -> + runCatching { enqueue(context, messageId) } + .onFailure { error -> + Log.e(TAG, "Unable to recover queued push message $messageId", error) + } + .isSuccess + }.all { it } + } + + fun recoverLater(context: ProfileContext) { + context.runOnlyInMainProcess { + if (!recoveringProfiles.add(context.relativePath)) return@runOnlyInMainProcess + scheduleRecovery(context, 0) + } + } + + private fun scheduleRecovery(context: ProfileContext, delaySeconds: Long) { + recoveryExecutor.schedule( + { + val recovered = runCatching { recover(context) } + .onFailure { error -> + Log.e(TAG, "Queued push recovery failed for ${context.relativePath}", error) + } + .getOrDefault(false) + if (recovered) { + recoveringProfiles.remove(context.relativePath) + } else { + scheduleRecovery(context, RECOVERY_RETRY_SECONDS) + } + }, + delaySeconds, + TimeUnit.SECONDS, + ) + } + + private fun workName(profilePath: String, messageId: String): String = + "push-message-$profilePath-$messageId" + + private const val MIN_BACKOFF_SECONDS = 10L + private const val RECOVERY_RETRY_SECONDS = 30L + private const val TAG = "PushMessageScheduler" + private val recoveringProfiles = ConcurrentHashMap.newKeySet() + private val recoveryExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "PushMessageRecovery").apply { isDaemon = true } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStore.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStore.kt new file mode 100644 index 00000000..6f558711 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStore.kt @@ -0,0 +1,191 @@ +/* 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.push + +import android.content.Context +import android.system.Os +import android.system.OsConstants +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileDescriptor +import java.io.FileNotFoundException +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.UUID + +data class StoredPushMessage( + val id: String, + val scope: String, + val payload: ByteArray, +) + +internal class CorruptPushMessageException( + message: String, + cause: Throwable? = null, +) : IOException(message, cause) + +/** Profile-scoped, crash-safe queue storage for decrypted push payloads. */ +class PushMessageStore internal constructor( + private val directory: File, +) { + constructor(context: Context) : this(File(context.noBackupFilesDir, DIRECTORY_NAME)) + + @Synchronized + fun persist(scope: String, payload: ByteArray, id: String = UUID.randomUUID().toString()): StoredPushMessage { + require(id.matches(SAFE_ID)) { "Invalid push message id" } + require(payload.size <= MAX_PAYLOAD_BYTES) { "Push payload is too large" } + directory.mkdirs() + if (isCompleted(id)) return StoredPushMessage(id, scope, payload.copyOf()) + + val target = file(id) + val temporary = File(directory, ".$id.tmp") + try { + FileOutputStream(temporary).use { output -> + DataOutputStream(output).use { data -> + data.writeInt(FORMAT_VERSION) + data.writeUTF(scope) + data.writeInt(payload.size) + data.write(payload) + data.flush() + output.fd.sync() + } + } + check(temporary.renameTo(target)) { "Unable to persist push message" } + syncDirectory() + } finally { + temporary.delete() + } + return StoredPushMessage(id, scope, payload.copyOf()) + } + + @Synchronized + fun get(id: String): StoredPushMessage? { + val source = file(id) + if (!source.isFile) return null + if (isCompleted(id)) return null + + try { + DataInputStream(FileInputStream(source)).use { data -> + if (data.readInt() != FORMAT_VERSION) { + throw CorruptPushMessageException("Unsupported push message format") + } + val scope = data.readUTF() + val size = data.readInt() + if (size < 0 || size > MAX_PAYLOAD_BYTES) { + throw CorruptPushMessageException("Invalid push payload size") + } + val payload = ByteArray(size) + data.readFully(payload) + return StoredPushMessage(id, scope, payload) + } + } catch (error: CorruptPushMessageException) { + throw error + } catch (error: FileNotFoundException) { + if (!source.exists()) return null + throw CorruptPushMessageException("Unable to open push message", error) + } catch (error: IOException) { + throw CorruptPushMessageException("Unable to read push message", error) + } + } + + @Synchronized + fun ids(): List { + if (!directory.isDirectory) return emptyList() + val files = directory.listFiles().orEmpty() + files.filter { + it.isFile && + it.extension == COMPLETED_EXTENSION && + it.nameWithoutExtension.matches(SAFE_ID) + }.forEach { isCompleted(it.nameWithoutExtension) } + + return files + .filter { + it.isFile && + it.extension == FILE_EXTENSION && + it.nameWithoutExtension.matches(SAFE_ID) + } + .filterNot { + val id = it.nameWithoutExtension + isCompleted(id) + } + .map { it.nameWithoutExtension } + } + + @Synchronized + fun complete(id: String): Boolean { + val source = file(id) + val completed = completedFile(id) + if (!source.exists()) { + return true + } + + if (!completed.isFile) { + directory.mkdirs() + val temporary = File(directory, ".$id.$COMPLETED_EXTENSION.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write(COMPLETED_MARKER) + output.flush() + output.fd.sync() + } + if (!temporary.renameTo(completed)) return false + syncDirectory() + } finally { + temporary.delete() + } + } + + source.delete() + return true + } + + private fun file(id: String): File { + require(id.matches(SAFE_ID)) { "Invalid push message id" } + return File(directory, "$id.$FILE_EXTENSION") + } + + private fun completedFile(id: String): File { + require(id.matches(SAFE_ID)) { "Invalid push message id" } + return File(directory, "$id.$COMPLETED_EXTENSION") + } + + private fun isCompleted(id: String): Boolean { + val completed = completedFile(id) + if (!completed.isFile) return false + + val source = file(id) + if (source.exists()) source.delete() + if (source.exists()) return true + + val expired = System.currentTimeMillis() - completed.lastModified() >= COMPLETED_TTL_MS + if (expired && completed.delete()) return false + return completed.exists() + } + + private fun syncDirectory() { + var descriptor: FileDescriptor? = null + try { + descriptor = Os.open(directory.path, OsConstants.O_RDONLY, 0) + Os.fsync(descriptor) + } catch (_: Exception) { + // File fsync remains the fallback on platforms that cannot fsync directories. + } finally { + descriptor?.let { runCatching { Os.close(it) } } + } + } + + companion object { + private const val DIRECTORY_NAME = "queued_push_messages" + private const val FILE_EXTENSION = "push" + private const val COMPLETED_EXTENSION = "delivered" + private const val COMPLETED_MARKER = 1 + private const val COMPLETED_TTL_MS = 7 * 24 * 60 * 60 * 1000L + private const val FORMAT_VERSION = 1 + private const val MAX_PAYLOAD_BYTES = 16 * 1024 * 1024 + private val SAFE_ID = Regex("[A-Za-z0-9_-]+") + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageWorker.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageWorker.kt new file mode 100644 index 00000000..7ab852d8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageWorker.kt @@ -0,0 +1,114 @@ +/* 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.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import eu.weblibre.flutter_mozilla_components.ActiveProfile +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class PushMessageWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun getForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + applicationContext.getSystemService(NotificationManager::class.java) + .createNotificationChannel( + NotificationChannel( + FOREGROUND_CHANNEL_ID, + "Web notification delivery", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps web notification delivery active" + setShowBadge(false) + }, + ) + } + + val appLabel = applicationContext.applicationInfo + .loadLabel(applicationContext.packageManager) + val notification = NotificationCompat.Builder(applicationContext, FOREGROUND_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync_noanim) + .setContentTitle(appLabel) + .setContentText("Delivering web notification") + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setLocalOnly(true) + .setOngoing(true) + .setSilent(true) + .setShowWhen(false) + .build() + val notificationId = (id.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) + return ForegroundInfo(notificationId, notification) + } + + override suspend fun doWork(): Result { + val queuedProfile = inputData.getString(KEY_PROFILE_PATH) ?: return Result.failure() + val messageId = inputData.getString(KEY_MESSAGE_ID) ?: return Result.failure() + return ActiveProfile.withProfileLock profile@{ + val activeProfile = runCatching { ActiveProfile.resolveContext(applicationContext) }.getOrNull() + ?: return@profile Result.retry() + // Keep the durable record for recovery when this profile becomes active again. + if (activeProfile.relativePath != queuedProfile) return@profile Result.success() + + val existing = GlobalComponents.components + if (existing != null && existing.profileApplicationContext.relativePath != queuedProfile) { + return@profile Result.success() + } + val initialized = existing != null || withContext(Dispatchers.Main.immediate) { + GlobalComponents.ensureExternalComponents(applicationContext) + } + if (!initialized) return@profile Result.retry() + + val push = GlobalComponents.pushForProfile(activeProfile) ?: return@profile Result.retry() + val store = PushMessageStore(activeProfile) + val message = try { + store.get(messageId) + } catch (error: CorruptPushMessageException) { + Log.e(TAG, "Discarding corrupt queued push message $messageId", error) + if (!store.complete(messageId)) { + Log.e(TAG, "Unable to mark corrupt push message $messageId as discarded") + } + return@profile Result.failure() + } ?: return@profile Result.success() + + try { + push.deliverMessage(message.scope, message.payload) + if (!store.complete(message.id)) { + Log.e(TAG, "Unable to mark delivered push message ${message.id} complete") + return@profile Result.retry() + } + Result.success() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Log.w( + TAG, + "Push delivery attempt ${runAttemptCount + 1} failed for $messageId", + error, + ) + Result.retry() + } + } + } + + companion object { + const val KEY_PROFILE_PATH = "profilePath" + const val KEY_MESSAGE_ID = "messageId" + private const val FOREGROUND_CHANNEL_ID = "weblibre_push_delivery" + private const val TAG = "PushMessageWorker" + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushPigeonMappers.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushPigeonMappers.kt new file mode 100644 index 00000000..ed96c029 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushPigeonMappers.kt @@ -0,0 +1,27 @@ +/* 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.push + +import eu.weblibre.flutter_mozilla_components.pigeons.PushDistributor +import eu.weblibre.flutter_mozilla_components.pigeons.PushDistributorStatus +import eu.weblibre.flutter_mozilla_components.pigeons.PushStatus + +internal fun PushStatusSnapshot.toPigeon() = PushStatus( + status = status.toPigeon(), + current = current?.toPigeon(), + available = available.map { it.toPigeon() }, + lastError = lastError, +) + +internal fun DistributorInfo.toPigeon() = + PushDistributor(packageName = packageName, label = label) + +internal fun DistributorStatus.toPigeon() = when (this) { + DistributorStatus.NONE_AVAILABLE -> PushDistributorStatus.NONE_AVAILABLE + DistributorStatus.NOT_SELECTED -> PushDistributorStatus.NOT_SELECTED + DistributorStatus.PENDING -> PushDistributorStatus.PENDING + DistributorStatus.READY -> PushDistributorStatus.READY + DistributorStatus.UNAVAILABLE -> PushDistributorStatus.UNAVAILABLE +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushProfileState.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushProfileState.kt new file mode 100644 index 00000000..97a04ef9 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/PushProfileState.kt @@ -0,0 +1,72 @@ +/* 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.push + +import android.content.Context +import android.content.SharedPreferences +import org.ironfoxoss.unifiedpush.PushError +import org.ironfoxoss.unifiedpush.SubscriptionsDB +import org.unifiedpush.android.connector.data.PushEndpoint + +internal object PushProfileState { + private const val PREFS_NAME = "weblibre_push" + const val KEY_SELECTED_DISTRIBUTOR = "selected_distributor" + private const val KEY_LAST_ERROR = "last_error" + private const val KEY_LAST_ERROR_SCOPE = "last_error_scope" + private const val KEY_LAST_ERROR_TYPE = "last_error_type" + + fun lastError(context: Context): String? = prefs(context).getString(KEY_LAST_ERROR, null) + + fun recordTemporaryUnavailable(context: Context, scope: String) { + recordError( + context, + scope, + "temporary_unavailable", + "Push service is temporarily unavailable", + ) + } + + fun recordError(context: Context, scope: String, type: String, message: String) { + prefs(context).edit() + .putString(KEY_LAST_ERROR, message) + .putString(KEY_LAST_ERROR_SCOPE, scope) + .putString(KEY_LAST_ERROR_TYPE, type) + .commit() + } + + fun clearError(context: Context, scope: String? = null) { + val prefs = prefs(context) + if (scope != null && prefs.getString(KEY_LAST_ERROR_SCOPE, null) != scope) return + prefs.edit() + .remove(KEY_LAST_ERROR) + .remove(KEY_LAST_ERROR_SCOPE) + .remove(KEY_LAST_ERROR_TYPE) + .commit() + } + + fun updateEndpoint(context: Context, scope: String, endpoint: PushEndpoint): Boolean { + val keys = endpoint.pubKeySet ?: return false + SubscriptionsDB(context).use { db -> + db.updateEndpoint(scope, endpoint.url, keys.pubKey, keys.auth) + } + clearError(context, scope) + return true + } + + fun removeEndpoint(context: Context, scope: String) { + SubscriptionsDB(context).use { it.removeEndpoint(scope) } + clearError(context, scope) + } + + fun errorType(error: PushError): String = when (error) { + is PushError.DB -> "database" + is PushError.Network -> "network" + is PushError.Registration -> "registration" + is PushError.ServiceUnavailable -> "service_unavailable" + } + + fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiver.kt new file mode 100644 index 00000000..9b519d25 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiver.kt @@ -0,0 +1,173 @@ +/* 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.push + +import android.content.Context +import android.content.Intent +import android.util.Log +import eu.weblibre.flutter_mozilla_components.ActiveProfile +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ProfileContext +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.Executors +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import org.ironfoxoss.unifiedpush.PushError +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.MessagingReceiver +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +class UnifiedPushReceiver : MessagingReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + val token = runCatching { intent.getStringExtra(EXTRA_TOKEN) }.getOrNull() + if (action !in SUPPORTED_ACTIONS || token.isNullOrBlank()) { + Log.w(TAG, "Ignoring invalid UnifiedPush broadcast") + return + } + + val pendingResult = goAsync() + synchronized(submissionLock) { + executor.execute { + try { + val profileContext = ActiveProfile.resolveContext(context.applicationContext) + if (profileContext == null) { + Log.e(TAG, "UnifiedPush broadcast has no active profile") + return@execute + } + currentToken.set(token) + currentMessageId.set(runCatching { intent.getStringExtra(EXTRA_MESSAGE_ID) }.getOrNull()) + super.onReceive(profileContext, intent) + } catch (error: Throwable) { + // An exception from onMessage deliberately prevents the connector from ACKing. + Log.e(TAG, "UnifiedPush broadcast processing failed", error) + } finally { + currentToken.remove() + currentMessageId.remove() + pendingResult.finish() + } + } + } + } + + override fun onMessage(context: Context, message: PushMessage, instance: String) { + check(message.decrypted) { "Refusing to ACK an undecrypted push message" } + val profileContext = context as? ProfileContext + ?: error("UnifiedPush message did not use a profile context") + val id = durableMessageId(instance, checkNotNull(currentToken.get()), currentMessageId.get()) + val stored = PushMessageStore(profileContext).persist(instance, message.content, id) + // MessagingReceiver sends its connector ACK only after this callback returns. + try { + PushMessageScheduler.enqueue(profileContext, stored.id) + } catch (error: Throwable) { + PushMessageScheduler.recoverLater(profileContext) + throw error + } + } + + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { + val push = GlobalComponents.pushForProfile(context) + if (push != null) { + runBlocking { push.onNewEndpoint(instance, endpoint) } + push.emitStatusChanged() + return + } + if (PushProfileState.updateEndpoint(context, instance, endpoint)) { + Log.i(TAG, "Persisted endpoint for cold profile callback") + } + } + + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + val error = reason.toPushError() + Log.w(TAG, "Push registration failed: ${error.message}") + val push = GlobalComponents.pushForProfile(context) + if (push != null) { + runBlocking { push.recordRegistrationError(instance, error) } + push.emitStatusChanged() + } else { + PushProfileState.recordError( + context, + instance, + PushProfileState.errorType(error), + error.message, + ) + } + } + + override fun onTempUnavailable(context: Context, instance: String) { + val push = GlobalComponents.pushForProfile(context) + if (push != null) { + runBlocking { push.recordTemporaryUnavailable(instance) } + push.emitStatusChanged() + } else { + PushProfileState.recordTemporaryUnavailable(context, instance) + } + } + + override fun onUnregistered(context: Context, instance: String) { + val push = GlobalComponents.pushForProfile(context) + if (push != null) { + runBlocking { push.onUnregistered(instance) } + push.emitStatusChanged() + } else { + PushProfileState.removeEndpoint(context, instance) + } + } + + private fun FailedReason.toPushError(): PushError = when (this) { + FailedReason.NETWORK -> PushError.Network("Push service needs network to register") + FailedReason.INTERNAL_ERROR -> PushError.ServiceUnavailable("Unknown error") + FailedReason.ACTION_REQUIRED -> + PushError.ServiceUnavailable("Push service waits for a user action") + FailedReason.VAPID_REQUIRED -> PushError.Registration("Push service requires VAPID") + } + + companion object { + private const val TAG = "UnifiedPushReceiver" + private const val EXTRA_TOKEN = "token" + private const val EXTRA_MESSAGE_ID = "id" + private val SUPPORTED_ACTIONS = setOf( + "org.unifiedpush.android.connector.MESSAGE", + "org.unifiedpush.android.connector.UNREGISTERED", + "org.unifiedpush.android.connector.NEW_ENDPOINT", + "org.unifiedpush.android.connector.REGISTRATION_FAILED", + "org.unifiedpush.android.connector.TEMP_UNAVAILABLE", + ) + private val executor = Executors.newSingleThreadExecutor() + private val submissionLock = Any() + private val currentToken = ThreadLocal() + private val currentMessageId = ThreadLocal() + + internal fun durableMessageId( + scope: String, + connectorToken: String, + connectorId: String?, + ): String { + if (connectorId == null) return UUID.randomUUID().toString() + return MessageDigest.getInstance("SHA-256") + .digest("$scope\u0000$connectorToken\u0000$connectorId".toByteArray()) + .joinToString("") { "%02x".format(it) } + } + + internal suspend fun runExclusive(block: suspend () -> T): T = + suspendCancellableCoroutine { continuation -> + synchronized(submissionLock) { + val operationJob = Job(continuation.context[Job]) + val future = executor.submit { + val result = runCatching { runBlocking(operationJob) { block() } } + operationJob.complete() + if (continuation.isActive) continuation.resumeWith(result) + } + continuation.invokeOnCancellation { + operationJob.cancel() + future.cancel(true) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebNotificationDrainCoordinator.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebNotificationDrainCoordinator.kt new file mode 100644 index 00000000..d58bc8b2 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebNotificationDrainCoordinator.kt @@ -0,0 +1,95 @@ +/* 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.push + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import mozilla.components.concept.engine.webnotifications.WebNotification +import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate +import mozilla.components.support.ktx.kotlin.getOrigin + +/** + * Wraps the engine's real [WebNotificationDelegate] so a headless push delivery + * can stay alive until the service worker actually posts its notification. + * + * The web push handoff to Gecko returns no completion signal, so the service + * worker's `event.waitUntil(... showNotification())` runs entirely + * asynchronously. This coordinator lets the delivery observe the actual + * [onShowNotification] callback instead of guessing a duration, forwarding the + * notification to the real delegate unchanged. + */ +class WebNotificationDrainCoordinator : WebNotificationDelegate { + @Volatile + var delegate: WebNotificationDelegate? = null + + private val lock = Any() + private var waiter: CompletableDeferred? = null + private var waitOrigin: String? = null + + override fun onShowNotification(webNotification: WebNotification): Deferred { + signal(webNotification.sourceUrl?.getOrigin()) + // Preserve the engine's completion contract by returning the real + // delegate's deferred; only fall back if wrapping failed. + return delegate?.onShowNotification(webNotification) ?: CompletableDeferred(false) + } + + override fun onCloseNotification(webNotification: WebNotification) { + delegate?.onCloseNotification(webNotification) + } + + /** + * Run [deliver] (the push handoff to Gecko) and then keep the caller + * suspended until a matching web notification is shown or [timeoutMillis] + * elapses. When a notification is observed, wait a further [graceMillis] so + * the delegate's asynchronous `notify` can land before the caller returns + * and the process loses foreground priority. + * + * Origin matching is best-effort: if either the push [origin] or the + * notification's origin cannot be derived, any shown notification satisfies + * the wait. Deliveries are serialized under the profile lock, so at most one + * drain is armed at a time. + */ + suspend fun drainWhileDelivering( + origin: String?, + timeoutMillis: Long, + graceMillis: Long, + deliver: suspend () -> Unit, + ) { + val deferred = CompletableDeferred() + synchronized(lock) { + waiter = deferred + waitOrigin = origin + } + try { + deliver() + val shown = withTimeoutOrNull(timeoutMillis) { + deferred.await() + true + } == true + if (shown && graceMillis > 0) { + delay(graceMillis) + } + } finally { + synchronized(lock) { + if (waiter === deferred) { + waiter = null + waitOrigin = null + } + } + } + } + + private fun signal(origin: String?) { + synchronized(lock) { + val pending = waiter ?: return + val target = waitOrigin + if (target == null || origin == null || target == origin) { + pending.complete(Unit) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebPushEngineIntegration.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebPushEngineIntegration.kt index 4594b212..de257d9a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebPushEngineIntegration.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/push/WebPushEngineIntegration.kt @@ -6,8 +6,11 @@ package eu.weblibre.flutter_mozilla_components.push import android.util.Base64 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.webpush.WebPushDelegate import mozilla.components.concept.engine.webpush.WebPushHandler @@ -44,6 +47,25 @@ class WebPushEngineIntegration( pushFeature.unregister(this) } + suspend fun deliverMessage(scope: PushScope, payload: ByteArray?) { + withContext(Dispatchers.Main.immediate) { + checkNotNull(handler) { "Web push handler is not initialized" } + .onPushMessage(scope, payload) + } + } + + suspend fun invalidateEndpoint(scope: PushScope) { + withContext(Dispatchers.Main.immediate) { + handler?.onSubscriptionChanged(scope) + } + } + + fun close() { + stop() + handler = null + coroutineScope.cancel() + } + override fun onMessageReceived(scope: PushScope, message: ByteArray?) { coroutineScope.launch { handler?.onPushMessage(scope, message) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/receivers/UnifiedPushReceiver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/receivers/UnifiedPushReceiver.kt deleted file mode 100644 index ba523be3..00000000 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/receivers/UnifiedPushReceiver.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* 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.receivers - -import android.content.Context -import android.content.Intent -import android.util.Log -import eu.weblibre.flutter_mozilla_components.ActiveProfile -import eu.weblibre.flutter_mozilla_components.GlobalComponents -import org.ironfoxoss.unifiedpush.PushError -import org.ironfoxoss.unifiedpush.UnifiedPushProcessor -import org.unifiedpush.android.connector.FailedReason -import org.unifiedpush.android.connector.MessagingReceiver -import org.unifiedpush.android.connector.data.PushEndpoint -import org.unifiedpush.android.connector.data.PushMessage - -class UnifiedPushReceiver : MessagingReceiver() { - companion object { - private const val TAG = "UnifiedPushReceiver" - } - - override fun onReceive(context: Context, intent: Intent) { - ActiveProfile.resolveFromDisk(context.applicationContext) - - if (GlobalComponents.components == null && - !GlobalComponents.ensureExternalComponents(context.applicationContext) - ) { - Log.e(TAG, "Unable to initialize components for UnifiedPush delivery") - return - } - - GlobalComponents.components?.push?.initialize() - - if (GlobalComponents.components == null) { - Log.e(TAG, "UnifiedPush delivery aborted because components are unavailable") - return - } - - super.onReceive(context, intent) - } - - override fun onMessage(context: Context, message: PushMessage, instance: String) { - UnifiedPushProcessor.requireInstance.onMessage( - scope = instance, - message = message, - ) - } - - override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { - UnifiedPushProcessor.requireInstance.onNewEndpoint( - scope = instance, - newEndpoint = endpoint, - ) - } - - override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { - UnifiedPushProcessor.requireInstance.onError(reason.toPushError()) - } - - override fun onUnregistered(context: Context, instance: String) { - UnifiedPushProcessor.requireInstance.onUnregistered(scope = instance) - } - - private fun FailedReason.toPushError(): PushError { - return when (this) { - FailedReason.NETWORK -> PushError.Network("Push service needs network to register") - FailedReason.INTERNAL_ERROR -> PushError.ServiceUnavailable("Unknown error") - FailedReason.ACTION_REQUIRED -> - PushError.ServiceUnavailable("Push service waits for a user action") - FailedReason.VAPID_REQUIRED -> PushError.Registration("Push service requires VAPID") - } - } -} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaContextPluginTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaContextPluginTest.kt index 74c2d5a0..902ef39b 100644 --- a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaContextPluginTest.kt +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaContextPluginTest.kt @@ -1,27 +1,11 @@ package eu.weblibre.flutter_mozilla_components -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ +import kotlin.test.assertNotNull internal class FlutterMozillaContextPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = FlutterMozillaComponentsPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } + @Test + fun pluginCanBeConstructed() { + assertNotNull(FlutterMozillaComponentsPlugin()) + } } diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfileTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfileTest.kt new file mode 100644 index 00000000..86f9efc6 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfileTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024-2025 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 . + */ +package eu.weblibre.flutter_mozilla_components + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +class ActiveProfileTest { + @Test + fun profileLockIsRetainedAcrossSuspension() = runBlocking { + val entered = CompletableDeferred() + val release = CompletableDeferred() + val secondEntered = CompletableDeferred() + val first = launch { + ActiveProfile.withProfileLock { + entered.complete(Unit) + release.await() + } + } + + entered.await() + val second = launch { + ActiveProfile.withProfileLock { secondEntered.complete(Unit) } + } + + withTimeoutOrNull(100) { secondEntered.await() } + assertFalse(secondEntered.isCompleted) + release.complete(Unit) + withTimeout(1_000) { + first.join() + second.join() + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/ProfileSwitchTimeoutTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/ProfileSwitchTimeoutTest.kt new file mode 100644 index 00000000..4534f526 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/ProfileSwitchTimeoutTest.kt @@ -0,0 +1,52 @@ +/* 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.push + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class ProfileSwitchTimeoutTest { + @Test + fun timeoutPreventsOperationFromStarting() = runBlocking { + var sideEffectRan = false + + val completed = runWithStartTimeout(50) { tryStart -> + delay(200) + if (tryStart()) sideEffectRan = true + } + + assertFalse(completed) + assertFalse(sideEffectRan) + } + + @Test + fun operationCompletesAfterStartingBeforeTimeout() = runBlocking { + val started = CompletableDeferred() + val release = CompletableDeferred() + var sideEffectRan = false + val result = async { + runWithStartTimeout(50) { tryStart -> + assertTrue(tryStart()) + started.complete(Unit) + release.await() + sideEffectRan = true + } + } + + started.await() + delay(100) + assertFalse(result.isCompleted) + + release.complete(Unit) + assertTrue(withTimeout(1_000) { result.await() }) + assertTrue(sideEffectRan) + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStoreTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStoreTest.kt new file mode 100644 index 00000000..a21bc9fe --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/PushMessageStoreTest.kt @@ -0,0 +1,132 @@ +/* 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.push + +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PushMessageStoreTest { + @Test + fun persistsListsAndDeletesMessage() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + store.persist("https://example.com", byteArrayOf(0, 1, 2, -1), "message-1") + + assertEquals(listOf("message-1"), store.ids()) + val stored = store.get("message-1") + assertEquals("https://example.com", stored?.scope) + assertContentEquals(byteArrayOf(0, 1, 2, -1), stored?.payload) + assertTrue(store.complete("message-1")) + assertNull(store.get("message-1")) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun replacingIdLeavesOneCompleteRecord() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + store.persist("old", byteArrayOf(1), "same-id") + store.persist("new", byteArrayOf(2, 3), "same-id") + + assertEquals(listOf("same-id"), store.ids()) + assertEquals("new", store.get("same-id")?.scope) + assertContentEquals(byteArrayOf(2, 3), store.get("same-id")?.payload) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun rejectsPathTraversalIds() { + val directory = Files.createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + assertFailsWith { + store.persist("scope", byteArrayOf(1), "../outside") + } + } finally { + directory.deleteRecursively() + } + } + + @Test + fun completedMessageIsNotRecovered() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + store.persist("scope", byteArrayOf(1), "completed") + + assertTrue(store.complete("completed")) + + assertTrue(store.ids().isEmpty()) + assertNull(store.get("completed")) + assertFalse(directory.resolve("completed.push").exists()) + assertTrue(directory.resolve("completed.delivered").isFile) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun rejectsAndDiscardsCorruptMessage() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + directory.resolve("corrupt.push").writeBytes(byteArrayOf(1, 2, 3)) + + assertFailsWith { + store.get("corrupt") + } + assertTrue(store.complete("corrupt")) + assertTrue(store.ids().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun deliveredMarkerSuppressesStalePayload() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + store.persist("scope", byteArrayOf(1), "stale") + directory.resolve("stale.delivered").writeBytes(byteArrayOf(1)) + + assertTrue(store.ids().isEmpty()) + assertNull(store.get("stale")) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun expiredDeliveredMarkerAllowsMessageIdReuse() { + val directory = createTempDirectory("push-store").toFile() + try { + val store = PushMessageStore(directory) + store.persist("old", byteArrayOf(1), "reused") + assertTrue(store.complete("reused")) + assertTrue(directory.resolve("reused.delivered").setLastModified(0)) + + store.persist("new", byteArrayOf(2), "reused") + + assertEquals(listOf("reused"), store.ids()) + assertEquals("new", store.get("reused")?.scope) + } finally { + directory.deleteRecursively() + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiverTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiverTest.kt new file mode 100644 index 00000000..cf999840 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/push/UnifiedPushReceiverTest.kt @@ -0,0 +1,79 @@ +/* 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.push + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +class UnifiedPushReceiverTest { + @Test + fun durableIdIsStableWithinConnectorRegistration() { + val first = UnifiedPushReceiver.durableMessageId("scope", "token", "message") + val second = UnifiedPushReceiver.durableMessageId("scope", "token", "message") + + assertEquals(first, second) + } + + @Test + fun durableIdSeparatesConnectorRegistrations() { + val first = UnifiedPushReceiver.durableMessageId("scope", "old-token", "message") + val second = UnifiedPushReceiver.durableMessageId("scope", "new-token", "message") + + assertNotEquals(first, second) + } + + @Test + fun cancellingExclusiveOperationReleasesQueue() = runBlocking { + val entered = CompletableDeferred() + val operation = launch { + UnifiedPushReceiver.runExclusive { + entered.complete(Unit) + awaitCancellation() + } + } + + entered.await() + operation.cancelAndJoin() + + withTimeout(1_000) { + UnifiedPushReceiver.runExclusive { } + } + } + + @Test + fun exclusiveOperationRetainsQueueAcrossSuspension() = runBlocking { + val entered = CompletableDeferred() + val release = CompletableDeferred() + val secondEntered = CompletableDeferred() + val first = launch { + UnifiedPushReceiver.runExclusive { + entered.complete(Unit) + release.await() + } + } + + entered.await() + val second = launch { + UnifiedPushReceiver.runExclusive { secondEntered.complete(Unit) } + } + + withTimeoutOrNull(100) { secondEntered.await() } + assertFalse(secondEntered.isCompleted) + release.complete(Unit) + withTimeout(1_000) { + first.join() + second.join() + } + } +} diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 8a575df6..4104d3ac 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -26,6 +26,7 @@ export 'src/domain/services/gecko_icon.dart'; export 'src/domain/services/gecko_logging.dart'; export 'src/domain/services/gecko_ml.dart'; export 'src/domain/services/gecko_pref.dart'; +export 'src/domain/services/gecko_push.dart'; export 'src/domain/services/gecko_readerable.dart'; export 'src/domain/services/gecko_selection_action.dart'; export 'src/domain/services/gecko_session.dart'; @@ -100,6 +101,10 @@ export 'src/pigeons/gecko.g.dart' MlProgressType, PhoneHitResult, ProxyLoadError, + PushDistributor, + PushDistributorStatus, + PushStatus, + PushSubscription, PwaIcon, PwaManifest, QueryParameterStripping, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart index ee3397fd..59b75c2b 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart @@ -69,10 +69,6 @@ class GeckoBrowserService { return _api.requestDefaultBrowser(); } - Future pickUnifiedPushDistributor() { - return _api.pickUnifiedPushDistributor(); - } - Future shutdown() { return _api.shutdown(); } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_push.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_push.dart new file mode 100644 index 00000000..2c47724b --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_push.dart @@ -0,0 +1,115 @@ +/* + * 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/. + */ + +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Service for UnifiedPush-backed web push. +/// +/// Web push is delivered by a separate distributor app (ntfy, Sunup, …) that the +/// user selects. With no distributor selected nothing can be delivered, so the +/// distributor selection doubles as the on/off switch for web push. +/// +/// Subscriptions are exposed read-only: Gecko owns the subscription state and +/// offers no app-facing channel to revoke one, so removal must go through the +/// site's notification permission. +class GeckoPushService extends GeckoPushEvents { + final GeckoPushApi _api; + final BinaryMessenger? _defaultBinaryMessenger; + final String _defaultMessageChannelSuffix; + + final _statusSubject = PublishSubject(); + BinaryMessenger? _eventBinaryMessenger; + String _eventMessageChannelSuffix = ''; + int? _lastStatusSequence; + bool _isSetUp = false; + bool _disposed = false; + Future? _disposeFuture; + + /// Stream of status snapshots pushed from native, emitted when a distributor + /// acknowledges registration, fails to register, or is uninstalled. + /// + /// Non-replaying: callers that need the current value must subscribe to this + /// before calling [getPushStatus], or they will miss any transition that lands + /// between the two. + Stream get statusChanges => _statusSubject.stream; + + GeckoPushService({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : _defaultBinaryMessenger = binaryMessenger, + _defaultMessageChannelSuffix = messageChannelSuffix, + _api = GeckoPushApi( + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + + /// Sets up the service to receive events from native. + /// + /// Must be called before events will be received. + void setUp({BinaryMessenger? binaryMessenger, String? messageChannelSuffix}) { + if (_isSetUp || _disposed) { + return; + } + + _eventBinaryMessenger = binaryMessenger ?? _defaultBinaryMessenger; + _eventMessageChannelSuffix = + messageChannelSuffix ?? _defaultMessageChannelSuffix; + GeckoPushEvents.setUp( + this, + binaryMessenger: _eventBinaryMessenger, + messageChannelSuffix: _eventMessageChannelSuffix, + ); + _isSetUp = true; + } + + Future getPushStatus() => _api.getPushStatus(); + + /// Selects [packageName], which must be one of [PushStatus.available]. + Future setDistributor(String packageName) => + _api.setDistributor(packageName); + + /// Forgets the current distributor, disabling web push delivery. + Future removeDistributor() => _api.removeDistributor(); + + Future renewRegistration() => _api.renewRegistration(); + + Future suspendForProfileSwitch(String targetProfileId) => + _api.suspendForProfileSwitch(targetProfileId); + + Future> getSubscriptions() => _api.getSubscriptions(); + + // GeckoPushEvents implementation + + @override + void onPushStatusChanged(int sequence, PushStatus status) { + if (_disposed || + (_lastStatusSequence != null && sequence <= _lastStatusSequence!)) { + return; + } + + _lastStatusSequence = sequence; + _statusSubject.add(status); + } + + Future dispose() { + return _disposeFuture ??= _dispose(); + } + + Future _dispose() async { + _disposed = true; + if (_isSetUp) { + GeckoPushEvents.setUp( + null, + binaryMessenger: _eventBinaryMessenger, + messageChannelSuffix: _eventMessageChannelSuffix, + ); + _isSetUp = false; + } + await _statusSubject.close(); + } +} diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 95a3c51e..c73ebfe1 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// Autogenerated from Pigeon (v27.1.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -449,6 +449,21 @@ enum AutoplayStatus { allowOnWifi, } +/// Lifecycle state of the selected UnifiedPush distributor. +enum PushDistributorStatus { + /// No distributor app is installed on the device. + noneAvailable, + /// Distributors are installed but the user has not chosen one. + notSelected, + /// A distributor is chosen but has not acknowledged our registration yet. + pending, + /// A distributor is chosen and has acknowledged our registration. + ready, + /// A distributor was chosen previously but is no longer installed. Web push + /// is dead in this state and there is no fallback transport. + unavailable, +} + /// Translation options that map to the Gecko Translations Options. /// /// @property downloadModel If the necessary models should be downloaded on request. If false, then @@ -491,6 +506,11 @@ class TranslationOptions { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TranslationOptions(downloadModel: $downloadModel)'; + } } /// A language supported by the translation engine. @@ -537,6 +557,11 @@ class TranslationLanguage { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TranslationLanguage(code: $code, localizedDisplayName: $localizedDisplayName)'; + } } /// Detected languages for a page. @@ -588,6 +613,11 @@ class TranslationDetectedLanguages { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TranslationDetectedLanguages(documentLangTag: $documentLangTag, supportedDocumentLang: $supportedDocumentLang, userPreferredLangTag: $userPreferredLangTag)'; + } } /// A from/to language pair for translation. @@ -634,6 +664,11 @@ class TranslationPair { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TranslationPair(fromLanguage: $fromLanguage, toLanguage: $toLanguage)'; + } } /// Browser-level translation engine state (global). @@ -685,6 +720,11 @@ class TranslationEngineStateData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TranslationEngineStateData(isEngineSupported: $isEngineSupported, fromLanguages: $fromLanguages, toLanguages: $toLanguages)'; + } } /// Per-tab translation state. @@ -776,6 +816,11 @@ class TabTranslationStateData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TabTranslationStateData(tabId: $tabId, isTranslated: $isTranslated, isTranslateProcessing: $isTranslateProcessing, isOfferTranslate: $isOfferTranslate, isExpectedTranslate: $isExpectedTranslate, detectedLanguageCode: $detectedLanguageCode, userPreferredLanguageCode: $userPreferredLanguageCode, requestedFromLanguage: $requestedFromLanguage, requestedToLanguage: $requestedToLanguage, translationErrorName: $translationErrorName, displayError: $displayError)'; + } } /// Value type that represents the state of reader mode/view. @@ -858,6 +903,11 @@ class ReaderState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ReaderState(readerable: $readerable, active: $active, checkRequired: $checkRequired, connectRequired: $connectRequired, baseUrl: $baseUrl, activeUrl: $activeUrl, scrollY: $scrollY)'; + } } /// Parameters for adding a new tab. @@ -939,6 +989,11 @@ class AddTabParams { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddTabParams(url: $url, startLoading: $startLoading, parentId: $parentId, flags: $flags, contextId: $contextId, source: $source, private: $private, historyMetadata: $historyMetadata, additionalHeaders: $additionalHeaders)'; + } } /// Details about the last playing media in this tab. @@ -1002,6 +1057,11 @@ class LastMediaAccessState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'LastMediaAccessState(lastMediaUrl: $lastMediaUrl, lastMediaAccess: $lastMediaAccess, mediaSessionActive: $mediaSessionActive)'; + } } /// Represents a set of history metadata values that uniquely identify a record. Note that @@ -1062,6 +1122,11 @@ class HistoryMetadataKey { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryMetadataKey(url: $url, searchTerm: $searchTerm, referrerUrl: $referrerUrl)'; + } } class PackageCategoryValue { @@ -1102,6 +1167,11 @@ class PackageCategoryValue { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PackageCategoryValue(value: $value)'; + } } /// Describes an external package. @@ -1150,6 +1220,11 @@ class ExternalPackage { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ExternalPackage(packageId: $packageId, category: $category)'; + } } class LoadUrlFlagsValue { @@ -1190,6 +1265,11 @@ class LoadUrlFlagsValue { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'LoadUrlFlagsValue(value: $value)'; + } } class SourceValue { @@ -1235,6 +1315,11 @@ class SourceValue { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SourceValue(id: $id, caller: $caller)'; + } } /// A tab that is no longer open and in the list of tabs, but that can be restored (recovered) at @@ -1367,6 +1452,11 @@ class TabState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TabState(id: $id, url: $url, parentId: $parentId, title: $title, searchTerm: $searchTerm, contextId: $contextId, readerState: $readerState, lastAccess: $lastAccess, createdAt: $createdAt, lastMediaAccessState: $lastMediaAccessState, private: $private, historyMetadata: $historyMetadata, source: $source, index: $index, hasFormData: $hasFormData)'; + } } /// A recoverable version of [TabState]. @@ -1415,6 +1505,11 @@ class RecoverableTab { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'RecoverableTab(engineSessionStateJson: $engineSessionStateJson, state: $state)'; + } } /// A request to load an [Icon]. @@ -1481,6 +1576,11 @@ class IconRequest { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'IconRequest(url: $url, size: $size, resources: $resources, color: $color, isPrivate: $isPrivate, waitOnNetworkLoad: $waitOnNetworkLoad)'; + } } class ResourceSize { @@ -1526,6 +1626,11 @@ class ResourceSize { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ResourceSize(height: $height, width: $width)'; + } } /// An icon resource that can be loaded. @@ -1587,6 +1692,11 @@ class Resource { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'Resource(url: $url, type: $type, sizes: $sizes, mimeType: $mimeType, maskable: $maskable)'; + } } /// An [Icon] returned by [BrowserIcons] after processing an [IconRequest] @@ -1647,6 +1757,11 @@ class IconResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'IconResult(image: $image, color: $color, source: $source, maskable: $maskable)'; + } } class CookiePartitionKey { @@ -1687,6 +1802,11 @@ class CookiePartitionKey { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'CookiePartitionKey(topLevelSite: $topLevelSite)'; + } } class Cookie { @@ -1787,6 +1907,11 @@ class Cookie { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'Cookie(domain: $domain, expirationDate: $expirationDate, firstPartyDomain: $firstPartyDomain, hostOnly: $hostOnly, httpOnly: $httpOnly, name: $name, partitionKey: $partitionKey, path: $path, secure: $secure, session: $session, sameSite: $sameSite, storeId: $storeId, value: $value)'; + } } class VisitInfo { @@ -1857,6 +1982,11 @@ class VisitInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'VisitInfo(url: $url, title: $title, visitTime: $visitTime, visitType: $visitType, previewImageUrl: $previewImageUrl, isRemote: $isRemote, contentId: $contentId)'; + } } class HistoryHighlightWeights { @@ -1902,6 +2032,11 @@ class HistoryHighlightWeights { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryHighlightWeights(viewTime: $viewTime, frequency: $frequency)'; + } } class HistoryHighlight { @@ -1962,6 +2097,11 @@ class HistoryHighlight { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryHighlight(score: $score, placeId: $placeId, url: $url, title: $title, previewImageUrl: $previewImageUrl)'; + } } class TopFrecentSiteInfo { @@ -2007,6 +2147,11 @@ class TopFrecentSiteInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TopFrecentSiteInfo(url: $url, title: $title)'; + } } /// Per-URL metadata maintained by Places. The unique identity of a record is @@ -2083,6 +2228,11 @@ class HistoryMetadata { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryMetadata(key: $key, title: $title, createdAt: $createdAt, updatedAt: $updatedAt, totalViewTime: $totalViewTime, documentType: $documentType, previewImageUrl: $previewImageUrl)'; + } } /// Frecency-ranked autocomplete suggestion. Backs `getSuggestions`. @@ -2136,6 +2286,11 @@ class HistorySuggestion { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistorySuggestion(url: $url, title: $title, score: $score)'; + } } /// Optional metadata observation for a URL. `null` fields are not written. @@ -2182,6 +2337,11 @@ class PageObservation { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PageObservation(title: $title, previewImageUrl: $previewImageUrl)'; + } } class HistoryItem { @@ -2227,6 +2387,11 @@ class HistoryItem { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryItem(url: $url, title: $title)'; + } } class HistoryState { @@ -2282,6 +2447,11 @@ class HistoryState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'HistoryState(items: $items, currentIndex: $currentIndex, canGoBack: $canGoBack, canGoForward: $canGoForward)'; + } } class ReaderableState { @@ -2330,6 +2500,11 @@ class ReaderableState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ReaderableState(readerable: $readerable, active: $active)'; + } } class SecurityInfoState { @@ -2380,6 +2555,11 @@ class SecurityInfoState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SecurityInfoState(secure: $secure, host: $host, issuer: $issuer)'; + } } class TabContentState { @@ -2465,6 +2645,11 @@ class TabContentState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TabContentState(id: $id, parentId: $parentId, contextId: $contextId, url: $url, title: $title, progress: $progress, isPrivate: $isPrivate, isFullScreen: $isFullScreen, isLoading: $isLoading, showToolbarAsExpanded: $showToolbarAsExpanded)'; + } } class FindResultState { @@ -2515,6 +2700,11 @@ class FindResultState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'FindResultState(activeMatchOrdinal: $activeMatchOrdinal, numberOfMatches: $numberOfMatches, isDoneCounting: $isDoneCounting)'; + } } class CustomSelectionAction { @@ -2565,6 +2755,11 @@ class CustomSelectionAction { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'CustomSelectionAction(id: $id, title: $title, pattern: $pattern)'; + } } class WebExtensionData { @@ -2630,6 +2825,11 @@ class WebExtensionData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'WebExtensionData(extensionId: $extensionId, title: $title, enabled: $enabled, badgeText: $badgeText, badgeTextColor: $badgeTextColor, badgeBackgroundColor: $badgeBackgroundColor)'; + } } class AddonInfo { @@ -2810,6 +3010,11 @@ class AddonInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonInfo(id: $id, displayName: $displayName, summary: $summary, description: $description, downloadUrl: $downloadUrl, version: $version, installedVersion: $installedVersion, translatedPermissions: $translatedPermissions, translatedRequiredDataCollectionPermissions: $translatedRequiredDataCollectionPermissions, authorName: $authorName, authorUrl: $authorUrl, homepageUrl: $homepageUrl, detailUrl: $detailUrl, ratingUrl: $ratingUrl, ratingAverage: $ratingAverage, ratingReviews: $ratingReviews, createdAt: $createdAt, updatedAt: $updatedAt, icon: $icon, isInstalled: $isInstalled, isEnabled: $isEnabled, isSupported: $isSupported, isAllowedInPrivateBrowsing: $isAllowedInPrivateBrowsing, isAutoUpdateEnabled: $isAutoUpdateEnabled, isLocalFileInstalled: $isLocalFileInstalled, optionsPageUrl: $optionsPageUrl, openOptionsPageInTab: $openOptionsPageInTab, disabledReason: $disabledReason, incognito: $incognito)'; + } } class AddonListingPreview { @@ -2860,6 +3065,11 @@ class AddonListingPreview { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonListingPreview(imageUrl: $imageUrl, thumbnailUrl: $thumbnailUrl, caption: $caption)'; + } } class AddonListing { @@ -3045,6 +3255,11 @@ class AddonListing { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonListing(id: $id, name: $name, summary: $summary, description: $description, iconUrl: $iconUrl, latestVersion: $latestVersion, downloadUrl: $downloadUrl, ratingAverage: $ratingAverage, ratingReviews: $ratingReviews, authorName: $authorName, authorUrl: $authorUrl, homepageUrl: $homepageUrl, detailUrl: $detailUrl, ratingUrl: $ratingUrl, averageDailyUsers: $averageDailyUsers, promoted: $promoted, previews: $previews, permissions: $permissions, hostPermissions: $hostPermissions, optionalPermissions: $optionalPermissions, dataCollectionPermissions: $dataCollectionPermissions, fileSize: $fileSize, lastUpdated: $lastUpdated, licenseName: $licenseName, licenseUrl: $licenseUrl, supportUrl: $supportUrl, supportEmail: $supportEmail, categories: $categories, hasPrivacyPolicy: $hasPrivacyPolicy, slug: $slug)'; + } } class AddonStoreInfo { @@ -3135,6 +3350,11 @@ class AddonStoreInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonStoreInfo(latestVersion: $latestVersion, latestXpiUrl: $latestXpiUrl, ratingAverage: $ratingAverage, ratingReviews: $ratingReviews, summary: $summary, description: $description, homepageUrl: $homepageUrl, detailUrl: $detailUrl, ratingUrl: $ratingUrl, authorName: $authorName, authorUrl: $authorUrl)'; + } } class AddonUpdateAttemptInfo { @@ -3190,6 +3410,11 @@ class AddonUpdateAttemptInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonUpdateAttemptInfo(addonId: $addonId, dateMillisecondsSinceEpoch: $dateMillisecondsSinceEpoch, status: $status, message: $message)'; + } } class GeckoSuggestion { @@ -3260,6 +3485,11 @@ class GeckoSuggestion { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoSuggestion(id: $id, type: $type, score: $score, title: $title, description: $description, editSuggestion: $editSuggestion, icon: $icon)'; + } } class TabContent { @@ -3325,6 +3555,11 @@ class TabContent { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TabContent(tabId: $tabId, fullContentMarkdown: $fullContentMarkdown, fullContentPlain: $fullContentPlain, isProbablyReaderable: $isProbablyReaderable, extractedContentMarkdown: $extractedContentMarkdown, extractedContentPlain: $extractedContentPlain)'; + } } class ContentBlocking { @@ -3380,6 +3615,11 @@ class ContentBlocking { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ContentBlocking(queryParameterStripping: $queryParameterStripping, queryParameterStrippingAllowList: $queryParameterStrippingAllowList, queryParameterStrippingStripList: $queryParameterStrippingStripList, bounceTrackingProtectionMode: $bounceTrackingProtectionMode)'; + } } class DohSettings { @@ -3435,6 +3675,11 @@ class DohSettings { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'DohSettings(dohSettingsMode: $dohSettingsMode, dohProviderUrl: $dohProviderUrl, dohDefaultProviderUrl: $dohDefaultProviderUrl, dohExceptionsList: $dohExceptionsList)'; + } } class GeckoEngineSettings { @@ -3705,6 +3950,11 @@ class GeckoEngineSettings { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoEngineSettings(javascriptEnabled: $javascriptEnabled, trackingProtectionPolicy: $trackingProtectionPolicy, httpsOnlyMode: $httpsOnlyMode, globalPrivacyControlEnabled: $globalPrivacyControlEnabled, preferredColorScheme: $preferredColorScheme, cookieBannerHandlingMode: $cookieBannerHandlingMode, cookieBannerHandlingModePrivateBrowsing: $cookieBannerHandlingModePrivateBrowsing, cookieBannerHandlingGlobalRules: $cookieBannerHandlingGlobalRules, cookieBannerHandlingGlobalRulesSubFrames: $cookieBannerHandlingGlobalRulesSubFrames, webContentIsolationStrategy: $webContentIsolationStrategy, userAgent: $userAgent, contentBlocking: $contentBlocking, enterpriseRootsEnabled: $enterpriseRootsEnabled, dohSettings: $dohSettings, fingerprintingProtectionOverrides: $fingerprintingProtectionOverrides, locales: $locales, useContentBlockingDatabase: $useContentBlockingDatabase, blockCookies: $blockCookies, customCookiePolicy: $customCookiePolicy, blockTrackingContent: $blockTrackingContent, trackingContentScope: $trackingContentScope, blockCryptominers: $blockCryptominers, blockFingerprinters: $blockFingerprinters, blockRedirectTrackers: $blockRedirectTrackers, blockSuspectedFingerprinters: $blockSuspectedFingerprinters, suspectedFingerprintersScope: $suspectedFingerprintersScope, allowListBaseline: $allowListBaseline, allowListConvenience: $allowListConvenience, blockAdsAnalyticsSocialTrackers: $blockAdsAnalyticsSocialTrackers, webFontsEnabled: $webFontsEnabled, automaticFontSizeAdjustment: $automaticFontSizeAdjustment, fontSizeFactor: $fontSizeFactor, fontInflationEnabled: $fontInflationEnabled, displayDensityOverride: $displayDensityOverride, screenWidthOverride: $screenWidthOverride, screenHeightOverride: $screenHeightOverride, inputAutoZoomEnabled: $inputAutoZoomEnabled, fissionEnabled: $fissionEnabled, isolatedProcessEnabled: $isolatedProcessEnabled, appZygoteProcessEnabled: $appZygoteProcessEnabled, extensionsWebAPIEnabled: $extensionsWebAPIEnabled, lnaBlocking: $lnaBlocking, lnaBlockTrackers: $lnaBlockTrackers, lnaEnabled: $lnaEnabled)'; + } } class AutocompleteResult { @@ -3765,6 +4015,11 @@ class AutocompleteResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AutocompleteResult(input: $input, text: $text, url: $url, source: $source, totalItems: $totalItems)'; + } } /// Represents all the different supported types of data that can be found from long clicking @@ -3816,6 +4071,11 @@ class UnknownHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'UnknownHitResult(src: $src, linkText: $linkText)'; + } } /// If the HTML element was of type 'HTMLImageElement'. @@ -3862,6 +4122,11 @@ class ImageHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ImageHitResult(src: $src, title: $title)'; + } } /// If the HTML element was of type 'HTMLVideoElement'. @@ -3908,6 +4173,11 @@ class VideoHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'VideoHitResult(src: $src, title: $title)'; + } } /// If the HTML element was of type 'HTMLAudioElement'. @@ -3954,6 +4224,11 @@ class AudioHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AudioHitResult(src: $src, title: $title)'; + } } /// If the HTML element was of type 'HTMLImageElement' and contained a URI. @@ -4000,6 +4275,11 @@ class ImageSrcHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ImageSrcHitResult(src: $src, uri: $uri)'; + } } /// The type used if the URI is prepended with 'tel:'. @@ -4041,6 +4321,11 @@ class PhoneHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PhoneHitResult(src: $src)'; + } } /// The type used if the URI is prepended with 'mailto:'. @@ -4082,6 +4367,11 @@ class EmailHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'EmailHitResult(src: $src)'; + } } /// The type used if the URI is prepended with 'geo:'. @@ -4123,6 +4413,11 @@ class GeoHitResult extends HitResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeoHitResult(src: $src)'; + } } class DownloadState { @@ -4243,6 +4538,11 @@ class DownloadState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'DownloadState(url: $url, fileName: $fileName, contentType: $contentType, contentLength: $contentLength, currentBytesCopied: $currentBytesCopied, status: $status, userAgent: $userAgent, destinationDirectory: $destinationDirectory, directoryPath: $directoryPath, referrerUrl: $referrerUrl, skipConfirmation: $skipConfirmation, openInApp: $openInApp, id: $id, sessionId: $sessionId, private: $private, createdTime: $createdTime, notificationId: $notificationId)'; + } } class ShareInternetResourceState { @@ -4298,6 +4598,11 @@ class ShareInternetResourceState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ShareInternetResourceState(url: $url, contentType: $contentType, private: $private, referrerUrl: $referrerUrl)'; + } } class AddonCollection { @@ -4348,6 +4653,11 @@ class AddonCollection { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AddonCollection(serverURL: $serverURL, collectionUser: $collectionUser, collectionName: $collectionName)'; + } } class SyncEngineStatus { @@ -4393,6 +4703,11 @@ class SyncEngineStatus { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncEngineStatus(engine: $engine, enabled: $enabled)'; + } } class SyncAccountInfo { @@ -4463,6 +4778,11 @@ class SyncAccountInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncAccountInfo(authenticated: $authenticated, syncing: $syncing, needsReauth: $needsReauth, email: $email, displayName: $displayName, lastSyncedAt: $lastSyncedAt, engines: $engines)'; + } } class SyncDevice { @@ -4518,6 +4838,11 @@ class SyncDevice { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncDevice(deviceId: $deviceId, displayName: $displayName, isCurrentDevice: $isCurrentDevice, canSendTab: $canSendTab)'; + } } class SyncIncomingTab { @@ -4573,6 +4898,11 @@ class SyncIncomingTab { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncIncomingTab(title: $title, url: $url, fromDeviceId: $fromDeviceId, fromDeviceName: $fromDeviceName)'; + } } class SyncRemoteTab { @@ -4633,6 +4963,11 @@ class SyncRemoteTab { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncRemoteTab(title: $title, url: $url, iconUrl: $iconUrl, lastUsed: $lastUsed, inactive: $inactive)'; + } } class SyncDeviceTabs { @@ -4683,6 +5018,11 @@ class SyncDeviceTabs { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SyncDeviceTabs(deviceId: $deviceId, deviceName: $deviceName, tabs: $tabs)'; + } } class GeckoPref { @@ -4743,6 +5083,11 @@ class GeckoPref { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoPref(name: $name, value: $value, defaultValue: $defaultValue, userValue: $userValue, hasUserChangedValue: $hasUserChangedValue)'; + } } /// Progress information for ML model operations @@ -4839,6 +5184,11 @@ class MlProgressData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'MlProgressData(modelType: $modelType, progress: $progress, type: $type, status: $status, totalLoaded: $totalLoaded, currentLoaded: $currentLoaded, total: $total, units: $units, ok: $ok, id: $id)'; + } } class GeckoProxySettings { @@ -4919,6 +5269,11 @@ class GeckoProxySettings { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoProxySettings(id: $id, title: $title, type: $type, host: $host, port: $port, username: $username, password: $password, proxyDNS: $proxyDNS, doNotProxyLocal: $doNotProxyLocal)'; + } } class ContainerSiteAssignment { @@ -4988,6 +5343,11 @@ class ContainerSiteAssignment { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ContainerSiteAssignment(requestId: $requestId, tabId: $tabId, originUrl: $originUrl, url: $url, blocked: $blocked, strict: $strict)'; + } } class ProxyLoadError { @@ -5043,6 +5403,11 @@ class ProxyLoadError { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ProxyLoadError(tabId: $tabId, contextId: $contextId, url: $url, errorType: $errorType)'; + } } class GeckoHeader { @@ -5088,6 +5453,11 @@ class GeckoHeader { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoHeader(key: $key, value: $value)'; + } } class GeckoFetchRequest { @@ -5188,6 +5558,11 @@ class GeckoFetchRequest { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoFetchRequest(url: $url, method: $method, headers: $headers, connectTimeoutMillis: $connectTimeoutMillis, readTimeoutMillis: $readTimeoutMillis, body: $body, redirect: $redirect, cookiePolicy: $cookiePolicy, useCaches: $useCaches, private: $private, useOhttp: $useOhttp, referrerUrl: $referrerUrl, conservative: $conservative)'; + } } class GeckoFetchResponse { @@ -5243,6 +5618,11 @@ class GeckoFetchResponse { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GeckoFetchResponse(url: $url, status: $status, headers: $headers, body: $body)'; + } } class BookmarkNode { @@ -5323,6 +5703,11 @@ class BookmarkNode { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'BookmarkNode(type: $type, guid: $guid, parentGuid: $parentGuid, position: $position, title: $title, url: $url, dateAdded: $dateAdded, lastModified: $lastModified, children: $children)'; + } } /// Class for making alterations to any bookmark node @@ -5379,6 +5764,11 @@ class BookmarkInfo { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'BookmarkInfo(parentGuid: $parentGuid, position: $position, title: $title, url: $url)'; + } } /// Site permissions data structure @@ -5480,6 +5870,11 @@ class SitePermissions { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SitePermissions(origin: $origin, camera: $camera, microphone: $microphone, location: $location, notification: $notification, persistentStorage: $persistentStorage, crossOriginStorageAccess: $crossOriginStorageAccess, mediaKeySystemAccess: $mediaKeySystemAccess, localDeviceAccess: $localDeviceAccess, localNetworkAccess: $localNetworkAccess, autoplayAudible: $autoplayAudible, autoplayInaudible: $autoplayInaudible, savedAt: $savedAt)'; + } } /// Tracking protection exception for a site @@ -5524,6 +5919,11 @@ class TrackingProtectionException { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TrackingProtectionException(url: $url)'; + } } /// Represents an icon from a PWA manifest. @@ -5575,6 +5975,11 @@ class PwaIcon { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PwaIcon(src: $src, sizes: $sizes, type: $type)'; + } } /// Represents a file entry in share target params. @@ -5621,6 +6026,11 @@ class ShareTargetFiles { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ShareTargetFiles(name: $name, accept: $accept)'; + } } /// Represents share target params. @@ -5677,6 +6087,11 @@ class ShareTargetParams { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ShareTargetParams(title: $title, text: $text, url: $url, files: $files)'; + } } /// Represents a share target for PWA. @@ -5733,6 +6148,11 @@ class ShareTarget { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ShareTarget(action: $action, method: $method, encType: $encType, params: $params)'; + } } /// Represents an external application resource. @@ -5789,6 +6209,11 @@ class ExternalApplicationResource { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ExternalApplicationResource(platform: $platform, url: $url, id: $id, minVersion: $minVersion)'; + } } /// Represents a PWA web app manifest. @@ -5929,6 +6354,11 @@ class PwaManifest { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PwaManifest(startUrl: $startUrl, name: $name, shortName: $shortName, display: $display, themeColor: $themeColor, backgroundColor: $backgroundColor, scope: $scope, description: $description, icons: $icons, dir: $dir, lang: $lang, orientation: $orientation, relatedApplications: $relatedApplications, preferRelatedApplications: $preferRelatedApplications, shareTarget: $shareTarget, currentUrl: $currentUrl, contextId: $contextId, installLabel: $installLabel)'; + } } /// Per-tab sandbox capture state shared with the native side. The Kotlin @@ -5999,6 +6429,11 @@ class SandboxCaptureEntry { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SandboxCaptureEntry(tabId: $tabId, captureId: $captureId, sourceUrl: $sourceUrl, redirectUrl: $redirectUrl, status: $status)'; + } } /// Configuration for native touch-gesture recognition. @@ -6084,8 +6519,215 @@ class GestureConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'GestureConfig(enabled: $enabled, strokeSize: $strokeSize, timeoutMs: $timeoutMs, maxFingers: $maxFingers, minStrokeIntervalMs: $minStrokeIntervalMs, activeGestureKeys: $activeGestureKeys)'; + } } +class PushDistributor { + PushDistributor({ + required this.packageName, + this.label, + }); + + String packageName; + + /// Human-readable app label, or null if the package is no longer installed. + String? label; + + List _toList() { + return [ + packageName, + label, + ]; + } + + Object encode() { + return _toList(); } + + static PushDistributor decode(Object result) { + result as List; + return PushDistributor( + packageName: result[0]! as String, + label: result[1] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PushDistributor || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(packageName, other.packageName) && _deepEquals(label, other.label); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PushDistributor(packageName: $packageName, label: $label)'; + } +} + +class PushStatus { + PushStatus({ + required this.status, + this.current, + required this.available, + this.lastError, + }); + + PushDistributorStatus status; + + PushDistributor? current; + + List available; + + /// Most recent distributor registration failure, or null if none. + /// + /// Held natively rather than delivered as a one-shot event: registrations are + /// attempted at startup and from background broadcasts, both of which can run + /// long before any Dart listener exists. + String? lastError; + + List _toList() { + return [ + status, + current, + available, + lastError, + ]; + } + + Object encode() { + return _toList(); } + + static PushStatus decode(Object result) { + result as List; + return PushStatus( + status: result[0]! as PushDistributorStatus, + current: result[1] as PushDistributor?, + available: (result[2]! as List).cast(), + lastError: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PushStatus || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(status, other.status) && _deepEquals(current, other.current) && _deepEquals(available, other.available) && _deepEquals(lastError, other.lastError); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PushStatus(status: $status, current: $current, available: $available, lastError: $lastError)'; + } +} + +class PushSubscription { + PushSubscription({ + required this.scope, + required this.hasEndpoint, + }); + + /// Subscription identifier, which for web push is the site's origin. + String scope; + + /// Whether the distributor has handed back an endpoint for this scope. + bool hasEndpoint; + + List _toList() { + return [ + scope, + hasEndpoint, + ]; + } + + Object encode() { + return _toList(); } + + static PushSubscription decode(Object result) { + result as List; + return PushSubscription( + scope: result[0]! as String, + hasEndpoint: result[1]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PushSubscription || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(scope, other.scope) && _deepEquals(hasEndpoint, other.hasEndpoint); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'PushSubscription(scope: $scope, hasEndpoint: $hasEndpoint)'; + } +} + + +// ignore: camel_case_types +class _PigeonCodecOverflow { + _PigeonCodecOverflow({required this.type, required this.wrapped}); + + int type; + Object? wrapped; + + Object encode() { + return [type, wrapped]; + } + + static _PigeonCodecOverflow decode(Object result) { + result as List; + return _PigeonCodecOverflow( + type: result[0]! as int, + wrapped: result[1], + ); + } + + Object? unwrap() { + if (wrapped == null) { + return null; + } + + switch (type) { + case 0: + return PushStatus.decode(wrapped!); + case 1: + return PushSubscription.decode(wrapped!); + } + return null; + } +} class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -6211,261 +6853,275 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is AutoplayStatus) { buffer.putUint8(167); writeValue(buffer, value.index); - } else if (value is TranslationOptions) { + } else if (value is PushDistributorStatus) { buffer.putUint8(168); - writeValue(buffer, value.encode()); - } else if (value is TranslationLanguage) { + writeValue(buffer, value.index); + } else if (value is TranslationOptions) { buffer.putUint8(169); writeValue(buffer, value.encode()); - } else if (value is TranslationDetectedLanguages) { + } else if (value is TranslationLanguage) { buffer.putUint8(170); writeValue(buffer, value.encode()); - } else if (value is TranslationPair) { + } else if (value is TranslationDetectedLanguages) { buffer.putUint8(171); writeValue(buffer, value.encode()); - } else if (value is TranslationEngineStateData) { + } else if (value is TranslationPair) { buffer.putUint8(172); writeValue(buffer, value.encode()); - } else if (value is TabTranslationStateData) { + } else if (value is TranslationEngineStateData) { buffer.putUint8(173); writeValue(buffer, value.encode()); - } else if (value is ReaderState) { + } else if (value is TabTranslationStateData) { buffer.putUint8(174); writeValue(buffer, value.encode()); - } else if (value is AddTabParams) { + } else if (value is ReaderState) { buffer.putUint8(175); writeValue(buffer, value.encode()); - } else if (value is LastMediaAccessState) { + } else if (value is AddTabParams) { buffer.putUint8(176); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadataKey) { + } else if (value is LastMediaAccessState) { buffer.putUint8(177); writeValue(buffer, value.encode()); - } else if (value is PackageCategoryValue) { + } else if (value is HistoryMetadataKey) { buffer.putUint8(178); writeValue(buffer, value.encode()); - } else if (value is ExternalPackage) { + } else if (value is PackageCategoryValue) { buffer.putUint8(179); writeValue(buffer, value.encode()); - } else if (value is LoadUrlFlagsValue) { + } else if (value is ExternalPackage) { buffer.putUint8(180); writeValue(buffer, value.encode()); - } else if (value is SourceValue) { + } else if (value is LoadUrlFlagsValue) { buffer.putUint8(181); writeValue(buffer, value.encode()); - } else if (value is TabState) { + } else if (value is SourceValue) { buffer.putUint8(182); writeValue(buffer, value.encode()); - } else if (value is RecoverableTab) { + } else if (value is TabState) { buffer.putUint8(183); writeValue(buffer, value.encode()); - } else if (value is IconRequest) { + } else if (value is RecoverableTab) { buffer.putUint8(184); writeValue(buffer, value.encode()); - } else if (value is ResourceSize) { + } else if (value is IconRequest) { buffer.putUint8(185); writeValue(buffer, value.encode()); - } else if (value is Resource) { + } else if (value is ResourceSize) { buffer.putUint8(186); writeValue(buffer, value.encode()); - } else if (value is IconResult) { + } else if (value is Resource) { buffer.putUint8(187); writeValue(buffer, value.encode()); - } else if (value is CookiePartitionKey) { + } else if (value is IconResult) { buffer.putUint8(188); writeValue(buffer, value.encode()); - } else if (value is Cookie) { + } else if (value is CookiePartitionKey) { buffer.putUint8(189); writeValue(buffer, value.encode()); - } else if (value is VisitInfo) { + } else if (value is Cookie) { buffer.putUint8(190); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlightWeights) { + } else if (value is VisitInfo) { buffer.putUint8(191); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlight) { + } else if (value is HistoryHighlightWeights) { buffer.putUint8(192); writeValue(buffer, value.encode()); - } else if (value is TopFrecentSiteInfo) { + } else if (value is HistoryHighlight) { buffer.putUint8(193); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadata) { + } else if (value is TopFrecentSiteInfo) { buffer.putUint8(194); writeValue(buffer, value.encode()); - } else if (value is HistorySuggestion) { + } else if (value is HistoryMetadata) { buffer.putUint8(195); writeValue(buffer, value.encode()); - } else if (value is PageObservation) { + } else if (value is HistorySuggestion) { buffer.putUint8(196); writeValue(buffer, value.encode()); - } else if (value is HistoryItem) { + } else if (value is PageObservation) { buffer.putUint8(197); writeValue(buffer, value.encode()); - } else if (value is HistoryState) { + } else if (value is HistoryItem) { buffer.putUint8(198); writeValue(buffer, value.encode()); - } else if (value is ReaderableState) { + } else if (value is HistoryState) { buffer.putUint8(199); writeValue(buffer, value.encode()); - } else if (value is SecurityInfoState) { + } else if (value is ReaderableState) { buffer.putUint8(200); writeValue(buffer, value.encode()); - } else if (value is TabContentState) { + } else if (value is SecurityInfoState) { buffer.putUint8(201); writeValue(buffer, value.encode()); - } else if (value is FindResultState) { + } else if (value is TabContentState) { buffer.putUint8(202); writeValue(buffer, value.encode()); - } else if (value is CustomSelectionAction) { + } else if (value is FindResultState) { buffer.putUint8(203); writeValue(buffer, value.encode()); - } else if (value is WebExtensionData) { + } else if (value is CustomSelectionAction) { buffer.putUint8(204); writeValue(buffer, value.encode()); - } else if (value is AddonInfo) { + } else if (value is WebExtensionData) { buffer.putUint8(205); writeValue(buffer, value.encode()); - } else if (value is AddonListingPreview) { + } else if (value is AddonInfo) { buffer.putUint8(206); writeValue(buffer, value.encode()); - } else if (value is AddonListing) { + } else if (value is AddonListingPreview) { buffer.putUint8(207); writeValue(buffer, value.encode()); - } else if (value is AddonStoreInfo) { + } else if (value is AddonListing) { buffer.putUint8(208); writeValue(buffer, value.encode()); - } else if (value is AddonUpdateAttemptInfo) { + } else if (value is AddonStoreInfo) { buffer.putUint8(209); writeValue(buffer, value.encode()); - } else if (value is GeckoSuggestion) { + } else if (value is AddonUpdateAttemptInfo) { buffer.putUint8(210); writeValue(buffer, value.encode()); - } else if (value is TabContent) { + } else if (value is GeckoSuggestion) { buffer.putUint8(211); writeValue(buffer, value.encode()); - } else if (value is ContentBlocking) { + } else if (value is TabContent) { buffer.putUint8(212); writeValue(buffer, value.encode()); - } else if (value is DohSettings) { + } else if (value is ContentBlocking) { buffer.putUint8(213); writeValue(buffer, value.encode()); - } else if (value is GeckoEngineSettings) { + } else if (value is DohSettings) { buffer.putUint8(214); writeValue(buffer, value.encode()); - } else if (value is AutocompleteResult) { + } else if (value is GeckoEngineSettings) { buffer.putUint8(215); writeValue(buffer, value.encode()); - } else if (value is UnknownHitResult) { + } else if (value is AutocompleteResult) { buffer.putUint8(216); writeValue(buffer, value.encode()); - } else if (value is ImageHitResult) { + } else if (value is UnknownHitResult) { buffer.putUint8(217); writeValue(buffer, value.encode()); - } else if (value is VideoHitResult) { + } else if (value is ImageHitResult) { buffer.putUint8(218); writeValue(buffer, value.encode()); - } else if (value is AudioHitResult) { + } else if (value is VideoHitResult) { buffer.putUint8(219); writeValue(buffer, value.encode()); - } else if (value is ImageSrcHitResult) { + } else if (value is AudioHitResult) { buffer.putUint8(220); writeValue(buffer, value.encode()); - } else if (value is PhoneHitResult) { + } else if (value is ImageSrcHitResult) { buffer.putUint8(221); writeValue(buffer, value.encode()); - } else if (value is EmailHitResult) { + } else if (value is PhoneHitResult) { buffer.putUint8(222); writeValue(buffer, value.encode()); - } else if (value is GeoHitResult) { + } else if (value is EmailHitResult) { buffer.putUint8(223); writeValue(buffer, value.encode()); - } else if (value is DownloadState) { + } else if (value is GeoHitResult) { buffer.putUint8(224); writeValue(buffer, value.encode()); - } else if (value is ShareInternetResourceState) { + } else if (value is DownloadState) { buffer.putUint8(225); writeValue(buffer, value.encode()); - } else if (value is AddonCollection) { + } else if (value is ShareInternetResourceState) { buffer.putUint8(226); writeValue(buffer, value.encode()); - } else if (value is SyncEngineStatus) { + } else if (value is AddonCollection) { buffer.putUint8(227); writeValue(buffer, value.encode()); - } else if (value is SyncAccountInfo) { + } else if (value is SyncEngineStatus) { buffer.putUint8(228); writeValue(buffer, value.encode()); - } else if (value is SyncDevice) { + } else if (value is SyncAccountInfo) { buffer.putUint8(229); writeValue(buffer, value.encode()); - } else if (value is SyncIncomingTab) { + } else if (value is SyncDevice) { buffer.putUint8(230); writeValue(buffer, value.encode()); - } else if (value is SyncRemoteTab) { + } else if (value is SyncIncomingTab) { buffer.putUint8(231); writeValue(buffer, value.encode()); - } else if (value is SyncDeviceTabs) { + } else if (value is SyncRemoteTab) { buffer.putUint8(232); writeValue(buffer, value.encode()); - } else if (value is GeckoPref) { + } else if (value is SyncDeviceTabs) { buffer.putUint8(233); writeValue(buffer, value.encode()); - } else if (value is MlProgressData) { + } else if (value is GeckoPref) { buffer.putUint8(234); writeValue(buffer, value.encode()); - } else if (value is GeckoProxySettings) { + } else if (value is MlProgressData) { buffer.putUint8(235); writeValue(buffer, value.encode()); - } else if (value is ContainerSiteAssignment) { + } else if (value is GeckoProxySettings) { buffer.putUint8(236); writeValue(buffer, value.encode()); - } else if (value is ProxyLoadError) { + } else if (value is ContainerSiteAssignment) { buffer.putUint8(237); writeValue(buffer, value.encode()); - } else if (value is GeckoHeader) { + } else if (value is ProxyLoadError) { buffer.putUint8(238); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchRequest) { + } else if (value is GeckoHeader) { buffer.putUint8(239); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchResponse) { + } else if (value is GeckoFetchRequest) { buffer.putUint8(240); writeValue(buffer, value.encode()); - } else if (value is BookmarkNode) { + } else if (value is GeckoFetchResponse) { buffer.putUint8(241); writeValue(buffer, value.encode()); - } else if (value is BookmarkInfo) { + } else if (value is BookmarkNode) { buffer.putUint8(242); writeValue(buffer, value.encode()); - } else if (value is SitePermissions) { + } else if (value is BookmarkInfo) { buffer.putUint8(243); writeValue(buffer, value.encode()); - } else if (value is TrackingProtectionException) { + } else if (value is SitePermissions) { buffer.putUint8(244); writeValue(buffer, value.encode()); - } else if (value is PwaIcon) { + } else if (value is TrackingProtectionException) { buffer.putUint8(245); writeValue(buffer, value.encode()); - } else if (value is ShareTargetFiles) { + } else if (value is PwaIcon) { buffer.putUint8(246); writeValue(buffer, value.encode()); - } else if (value is ShareTargetParams) { + } else if (value is ShareTargetFiles) { buffer.putUint8(247); writeValue(buffer, value.encode()); - } else if (value is ShareTarget) { + } else if (value is ShareTargetParams) { buffer.putUint8(248); writeValue(buffer, value.encode()); - } else if (value is ExternalApplicationResource) { + } else if (value is ShareTarget) { buffer.putUint8(249); writeValue(buffer, value.encode()); - } else if (value is PwaManifest) { + } else if (value is ExternalApplicationResource) { buffer.putUint8(250); writeValue(buffer, value.encode()); - } else if (value is SandboxCaptureEntry) { + } else if (value is PwaManifest) { buffer.putUint8(251); writeValue(buffer, value.encode()); - } else if (value is GestureConfig) { + } else if (value is SandboxCaptureEntry) { buffer.putUint8(252); writeValue(buffer, value.encode()); + } else if (value is GestureConfig) { + buffer.putUint8(253); + writeValue(buffer, value.encode()); + } else if (value is PushDistributor) { + buffer.putUint8(254); + writeValue(buffer, value.encode()); + } else if (value is PushStatus) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 0, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is PushSubscription) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 1, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); } else { super.writeValue(buffer, value); } @@ -6592,175 +7248,183 @@ class _PigeonCodec extends StandardMessageCodec { final value = readValue(buffer) as int?; return value == null ? null : AutoplayStatus.values[value]; case 168: - return TranslationOptions.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : PushDistributorStatus.values[value]; case 169: - return TranslationLanguage.decode(readValue(buffer)!); + return TranslationOptions.decode(readValue(buffer)!); case 170: - return TranslationDetectedLanguages.decode(readValue(buffer)!); + return TranslationLanguage.decode(readValue(buffer)!); case 171: - return TranslationPair.decode(readValue(buffer)!); + return TranslationDetectedLanguages.decode(readValue(buffer)!); case 172: - return TranslationEngineStateData.decode(readValue(buffer)!); + return TranslationPair.decode(readValue(buffer)!); case 173: - return TabTranslationStateData.decode(readValue(buffer)!); + return TranslationEngineStateData.decode(readValue(buffer)!); case 174: - return ReaderState.decode(readValue(buffer)!); + return TabTranslationStateData.decode(readValue(buffer)!); case 175: - return AddTabParams.decode(readValue(buffer)!); + return ReaderState.decode(readValue(buffer)!); case 176: - return LastMediaAccessState.decode(readValue(buffer)!); + return AddTabParams.decode(readValue(buffer)!); case 177: - return HistoryMetadataKey.decode(readValue(buffer)!); + return LastMediaAccessState.decode(readValue(buffer)!); case 178: - return PackageCategoryValue.decode(readValue(buffer)!); + return HistoryMetadataKey.decode(readValue(buffer)!); case 179: - return ExternalPackage.decode(readValue(buffer)!); + return PackageCategoryValue.decode(readValue(buffer)!); case 180: - return LoadUrlFlagsValue.decode(readValue(buffer)!); + return ExternalPackage.decode(readValue(buffer)!); case 181: - return SourceValue.decode(readValue(buffer)!); + return LoadUrlFlagsValue.decode(readValue(buffer)!); case 182: - return TabState.decode(readValue(buffer)!); + return SourceValue.decode(readValue(buffer)!); case 183: - return RecoverableTab.decode(readValue(buffer)!); + return TabState.decode(readValue(buffer)!); case 184: - return IconRequest.decode(readValue(buffer)!); + return RecoverableTab.decode(readValue(buffer)!); case 185: - return ResourceSize.decode(readValue(buffer)!); + return IconRequest.decode(readValue(buffer)!); case 186: - return Resource.decode(readValue(buffer)!); + return ResourceSize.decode(readValue(buffer)!); case 187: - return IconResult.decode(readValue(buffer)!); + return Resource.decode(readValue(buffer)!); case 188: - return CookiePartitionKey.decode(readValue(buffer)!); + return IconResult.decode(readValue(buffer)!); case 189: - return Cookie.decode(readValue(buffer)!); + return CookiePartitionKey.decode(readValue(buffer)!); case 190: - return VisitInfo.decode(readValue(buffer)!); + return Cookie.decode(readValue(buffer)!); case 191: - return HistoryHighlightWeights.decode(readValue(buffer)!); + return VisitInfo.decode(readValue(buffer)!); case 192: - return HistoryHighlight.decode(readValue(buffer)!); + return HistoryHighlightWeights.decode(readValue(buffer)!); case 193: - return TopFrecentSiteInfo.decode(readValue(buffer)!); + return HistoryHighlight.decode(readValue(buffer)!); case 194: - return HistoryMetadata.decode(readValue(buffer)!); + return TopFrecentSiteInfo.decode(readValue(buffer)!); case 195: - return HistorySuggestion.decode(readValue(buffer)!); + return HistoryMetadata.decode(readValue(buffer)!); case 196: - return PageObservation.decode(readValue(buffer)!); + return HistorySuggestion.decode(readValue(buffer)!); case 197: - return HistoryItem.decode(readValue(buffer)!); + return PageObservation.decode(readValue(buffer)!); case 198: - return HistoryState.decode(readValue(buffer)!); + return HistoryItem.decode(readValue(buffer)!); case 199: - return ReaderableState.decode(readValue(buffer)!); + return HistoryState.decode(readValue(buffer)!); case 200: - return SecurityInfoState.decode(readValue(buffer)!); + return ReaderableState.decode(readValue(buffer)!); case 201: - return TabContentState.decode(readValue(buffer)!); + return SecurityInfoState.decode(readValue(buffer)!); case 202: - return FindResultState.decode(readValue(buffer)!); + return TabContentState.decode(readValue(buffer)!); case 203: - return CustomSelectionAction.decode(readValue(buffer)!); + return FindResultState.decode(readValue(buffer)!); case 204: - return WebExtensionData.decode(readValue(buffer)!); + return CustomSelectionAction.decode(readValue(buffer)!); case 205: - return AddonInfo.decode(readValue(buffer)!); + return WebExtensionData.decode(readValue(buffer)!); case 206: - return AddonListingPreview.decode(readValue(buffer)!); + return AddonInfo.decode(readValue(buffer)!); case 207: - return AddonListing.decode(readValue(buffer)!); + return AddonListingPreview.decode(readValue(buffer)!); case 208: - return AddonStoreInfo.decode(readValue(buffer)!); + return AddonListing.decode(readValue(buffer)!); case 209: - return AddonUpdateAttemptInfo.decode(readValue(buffer)!); + return AddonStoreInfo.decode(readValue(buffer)!); case 210: - return GeckoSuggestion.decode(readValue(buffer)!); + return AddonUpdateAttemptInfo.decode(readValue(buffer)!); case 211: - return TabContent.decode(readValue(buffer)!); + return GeckoSuggestion.decode(readValue(buffer)!); case 212: - return ContentBlocking.decode(readValue(buffer)!); + return TabContent.decode(readValue(buffer)!); case 213: - return DohSettings.decode(readValue(buffer)!); + return ContentBlocking.decode(readValue(buffer)!); case 214: - return GeckoEngineSettings.decode(readValue(buffer)!); + return DohSettings.decode(readValue(buffer)!); case 215: - return AutocompleteResult.decode(readValue(buffer)!); + return GeckoEngineSettings.decode(readValue(buffer)!); case 216: - return UnknownHitResult.decode(readValue(buffer)!); + return AutocompleteResult.decode(readValue(buffer)!); case 217: - return ImageHitResult.decode(readValue(buffer)!); + return UnknownHitResult.decode(readValue(buffer)!); case 218: - return VideoHitResult.decode(readValue(buffer)!); + return ImageHitResult.decode(readValue(buffer)!); case 219: - return AudioHitResult.decode(readValue(buffer)!); + return VideoHitResult.decode(readValue(buffer)!); case 220: - return ImageSrcHitResult.decode(readValue(buffer)!); + return AudioHitResult.decode(readValue(buffer)!); case 221: - return PhoneHitResult.decode(readValue(buffer)!); + return ImageSrcHitResult.decode(readValue(buffer)!); case 222: - return EmailHitResult.decode(readValue(buffer)!); + return PhoneHitResult.decode(readValue(buffer)!); case 223: - return GeoHitResult.decode(readValue(buffer)!); + return EmailHitResult.decode(readValue(buffer)!); case 224: - return DownloadState.decode(readValue(buffer)!); + return GeoHitResult.decode(readValue(buffer)!); case 225: - return ShareInternetResourceState.decode(readValue(buffer)!); + return DownloadState.decode(readValue(buffer)!); case 226: - return AddonCollection.decode(readValue(buffer)!); + return ShareInternetResourceState.decode(readValue(buffer)!); case 227: - return SyncEngineStatus.decode(readValue(buffer)!); + return AddonCollection.decode(readValue(buffer)!); case 228: - return SyncAccountInfo.decode(readValue(buffer)!); + return SyncEngineStatus.decode(readValue(buffer)!); case 229: - return SyncDevice.decode(readValue(buffer)!); + return SyncAccountInfo.decode(readValue(buffer)!); case 230: - return SyncIncomingTab.decode(readValue(buffer)!); + return SyncDevice.decode(readValue(buffer)!); case 231: - return SyncRemoteTab.decode(readValue(buffer)!); + return SyncIncomingTab.decode(readValue(buffer)!); case 232: - return SyncDeviceTabs.decode(readValue(buffer)!); + return SyncRemoteTab.decode(readValue(buffer)!); case 233: - return GeckoPref.decode(readValue(buffer)!); + return SyncDeviceTabs.decode(readValue(buffer)!); case 234: - return MlProgressData.decode(readValue(buffer)!); + return GeckoPref.decode(readValue(buffer)!); case 235: - return GeckoProxySettings.decode(readValue(buffer)!); + return MlProgressData.decode(readValue(buffer)!); case 236: - return ContainerSiteAssignment.decode(readValue(buffer)!); + return GeckoProxySettings.decode(readValue(buffer)!); case 237: - return ProxyLoadError.decode(readValue(buffer)!); + return ContainerSiteAssignment.decode(readValue(buffer)!); case 238: - return GeckoHeader.decode(readValue(buffer)!); + return ProxyLoadError.decode(readValue(buffer)!); case 239: - return GeckoFetchRequest.decode(readValue(buffer)!); + return GeckoHeader.decode(readValue(buffer)!); case 240: - return GeckoFetchResponse.decode(readValue(buffer)!); + return GeckoFetchRequest.decode(readValue(buffer)!); case 241: - return BookmarkNode.decode(readValue(buffer)!); + return GeckoFetchResponse.decode(readValue(buffer)!); case 242: - return BookmarkInfo.decode(readValue(buffer)!); + return BookmarkNode.decode(readValue(buffer)!); case 243: - return SitePermissions.decode(readValue(buffer)!); + return BookmarkInfo.decode(readValue(buffer)!); case 244: - return TrackingProtectionException.decode(readValue(buffer)!); + return SitePermissions.decode(readValue(buffer)!); case 245: - return PwaIcon.decode(readValue(buffer)!); + return TrackingProtectionException.decode(readValue(buffer)!); case 246: - return ShareTargetFiles.decode(readValue(buffer)!); + return PwaIcon.decode(readValue(buffer)!); case 247: - return ShareTargetParams.decode(readValue(buffer)!); + return ShareTargetFiles.decode(readValue(buffer)!); case 248: - return ShareTarget.decode(readValue(buffer)!); + return ShareTargetParams.decode(readValue(buffer)!); case 249: - return ExternalApplicationResource.decode(readValue(buffer)!); + return ShareTarget.decode(readValue(buffer)!); case 250: - return PwaManifest.decode(readValue(buffer)!); + return ExternalApplicationResource.decode(readValue(buffer)!); case 251: - return SandboxCaptureEntry.decode(readValue(buffer)!); + return PwaManifest.decode(readValue(buffer)!); case 252: + return SandboxCaptureEntry.decode(readValue(buffer)!); + case 253: return GestureConfig.decode(readValue(buffer)!); + case 254: + return PushDistributor.decode(readValue(buffer)!); + case 255: + final _PigeonCodecOverflow wrapper = _PigeonCodecOverflow.decode(readValue(buffer)!); + return wrapper.unwrap(); default: return super.readValueOfType(type, buffer); } @@ -6768,8 +7432,8 @@ class _PigeonCodec extends StandardMessageCodec { } class GeckoBrowserApi { - /// Constructor for [GeckoBrowserApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoBrowserApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoBrowserApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -6909,25 +7573,6 @@ class GeckoBrowserApi { ; } - Future pickUnifiedPushDistributor() async { - final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.pickUnifiedPushDistributor$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; - return pigeonVar_replyValue! as bool; - } - Future shutdown() async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.shutdown$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( @@ -6948,8 +7593,8 @@ class GeckoBrowserApi { } class GeckoSyncApi { - /// Constructor for [GeckoSyncApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoSyncApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -7221,8 +7866,8 @@ class GeckoSyncApi { } class GeckoEngineSettingsApi { - /// Constructor for [GeckoEngineSettingsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoEngineSettingsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoEngineSettingsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -7455,8 +8100,8 @@ class GeckoEngineSettingsApi { } class GeckoSessionApi { - /// Constructor for [GeckoSessionApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoSessionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoSessionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -7794,8 +8439,8 @@ class GeckoSessionApi { } class GeckoTabsApi { - /// Constructor for [GeckoTabsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoTabsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoTabsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8105,8 +8750,8 @@ class GeckoTabsApi { } class GeckoFindApi { - /// Constructor for [GeckoFindApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoFindApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoFindApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8173,8 +8818,8 @@ class GeckoFindApi { } class GeckoIconsApi { - /// Constructor for [GeckoIconsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoIconsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoIconsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8206,8 +8851,8 @@ class GeckoIconsApi { } class GeckoPrefApi { - /// Constructor for [GeckoPrefApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoPrefApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoPrefApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8348,8 +8993,8 @@ class GeckoPrefApi { } class GeckoMlApi { - /// Constructor for [GeckoMlApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoMlApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoMlApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8418,8 +9063,8 @@ class GeckoMlApi { } class GeckoBrowserExtensionApi { - /// Constructor for [GeckoBrowserExtensionApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoBrowserExtensionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoBrowserExtensionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8451,8 +9096,8 @@ class GeckoBrowserExtensionApi { } class GeckoContainerProxyApi { - /// Constructor for [GeckoContainerProxyApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoContainerProxyApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoContainerProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -8689,8 +9334,8 @@ class GeckoContainerProxyApi { } class GeckoCookieApi { - /// Constructor for [GeckoCookieApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoCookieApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoCookieApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -9482,8 +10127,8 @@ abstract class GeckoLogging { } class ReaderViewEvents { - /// Constructor for [ReaderViewEvents]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [ReaderViewEvents]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. ReaderViewEvents({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -9564,8 +10209,8 @@ abstract class ReaderViewController { } class GeckoSelectionActionController { - /// Constructor for [GeckoSelectionActionController]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoSelectionActionController]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoSelectionActionController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -9628,8 +10273,8 @@ abstract class GeckoSelectionActionEvents { } class GeckoAddonsApi { - /// Constructor for [GeckoAddonsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoAddonsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoAddonsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10092,8 +10737,8 @@ abstract class GeckoAddonEvents { } class GeckoSuggestionApi { - /// Constructor for [GeckoSuggestionApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoSuggestionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoSuggestionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10208,8 +10853,8 @@ abstract class GeckoTabContentEvents { } class GeckoDeleteBrowsingDataController { - /// Constructor for [GeckoDeleteBrowsingDataController]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoDeleteBrowsingDataController]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoDeleteBrowsingDataController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10409,8 +11054,8 @@ abstract class GeckoHistoryEvents { } class GeckoHistoryApi { - /// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoHistoryApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10798,8 +11443,8 @@ class GeckoHistoryApi { } class GeckoDownloadsApi { - /// Constructor for [GeckoDownloadsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoDownloadsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoDownloadsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10917,8 +11562,8 @@ abstract class BrowserExtensionEvents { } class GeckoFetchApi { - /// Constructor for [GeckoFetchApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoFetchApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoFetchApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -10959,8 +11604,8 @@ class GeckoFetchApi { /// 2. Updating the vertical clipping as toolbar animates via [setVerticalClipping] /// 3. GeckoView internally adjusts viewport and notifies the website class GeckoViewportApi { - /// Constructor for [GeckoViewportApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoViewportApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoViewportApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11104,8 +11749,8 @@ abstract class GeckoViewportEvents { } class GeckoBookmarksApi { - /// Constructor for [GeckoBookmarksApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoBookmarksApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoBookmarksApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11341,8 +11986,8 @@ class GeckoBookmarksApi { /// API for managing site permissions stored in GeckoView class GeckoSitePermissionsApi { - /// Constructor for [GeckoSitePermissionsApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoSitePermissionsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoSitePermissionsApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11414,8 +12059,8 @@ class GeckoSitePermissionsApi { /// Native wrapper for Mozilla's Public Suffix List class GeckoPublicSuffixListApi { - /// Constructor for [GeckoPublicSuffixListApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoPublicSuffixListApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoPublicSuffixListApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11454,8 +12099,8 @@ class GeckoPublicSuffixListApi { /// to allow Flutter code to add/remove/check tracking protection exceptions /// on a per-site basis. class GeckoTrackingProtectionApi { - /// Constructor for [GeckoTrackingProtectionApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoTrackingProtectionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoTrackingProtectionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11604,8 +12249,8 @@ class GeckoTrackingProtectionApi { /// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter /// code to check if native apps can handle URLs and launch them directly. class GeckoAppLinksApi { - /// Constructor for [GeckoAppLinksApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoAppLinksApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoAppLinksApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11674,8 +12319,8 @@ class GeckoAppLinksApi { /// Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage /// to provide PWA install and query functionality to Flutter. class GeckoPwaApi { - /// Constructor for [GeckoPwaApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoPwaApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoPwaApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11770,8 +12415,8 @@ class GeckoPwaApi { /// Dart → Kotlin. Mutates the native [SandboxCaptureRegistry] that the /// request interceptor consults on every load. class SandboxCaptureApi { - /// Constructor for [SandboxCaptureApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [SandboxCaptureApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. SandboxCaptureApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -11914,8 +12559,8 @@ abstract class SandboxCaptureHostEvents { /// Dart → Kotlin. Pushes the current gesture-recognition configuration. class GeckoGestureApi { - /// Constructor for [GeckoGestureApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [GeckoGestureApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. GeckoGestureApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -12037,3 +12682,177 @@ abstract class GeckoGestureEvents { } } } + +/// Dart → Kotlin. UnifiedPush distributor management and web push introspection. +class GeckoPushApi { + /// Constructor for [GeckoPushApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + GeckoPushApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future getPushStatus() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.getPushStatus$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as PushStatus; + } + + /// Selects [packageName], which must be one of [PushStatus.available]. + /// + /// The picker is built in Dart rather than delegated to the connector's own + /// dialog, which would save the selection against a non-profile context. + Future setDistributor(String packageName) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.setDistributor$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([packageName]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Forgets the current distributor. This is the off switch for web push. + Future removeDistributor() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.removeDistributor$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + Future renewRegistration() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.renewRegistration$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Pauses push transport for the current profile before switching profiles. + /// Site subscriptions and the chosen distributor are retained for restoration + /// when this profile becomes active again. + Future suspendForProfileSwitch(String targetProfileId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.suspendForProfileSwitch$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([targetProfileId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Subscriptions Gecko has created, read from the UnifiedPush store. Read-only: + /// there is no app→Gecko channel to revoke a subscription, so removal has to go + /// through the site's notification permission instead. + Future> getSubscriptions() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushApi.getSubscriptions$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } +} + +/// Kotlin → Dart. Push registration lifecycle. +/// +/// Registration failures reach Dart through [PushStatus.lastError] rather than a +/// dedicated event, so a failure raised before any Dart listener is attached is +/// still visible the first time the settings screen reads the status. +abstract class GeckoPushEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// [sequence] Event sequence number for ordering. + void onPushStatusChanged(int sequence, PushStatus status); + + static void setUp(GeckoPushEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushEvents.onPushStatusChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final PushStatus arg_status = args[1]! as PushStatus; + try { + api.onPushStatusChanged(arg_sequence, arg_status); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index dd5f3b9e..504d0481 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1417,8 +1417,6 @@ abstract class GeckoBrowserApi { }); bool isDefaultBrowser(); void requestDefaultBrowser(); - @async - bool pickUnifiedPushDistributor(); void shutdown(); } @@ -3146,3 +3144,105 @@ abstract class GeckoGestureEvents { /// [sequence] Event sequence number for ordering. void onGestureReset(int sequence); } + +/// Lifecycle state of the selected UnifiedPush distributor. +enum PushDistributorStatus { + /// No distributor app is installed on the device. + noneAvailable, + + /// Distributors are installed but the user has not chosen one. + notSelected, + + /// A distributor is chosen but has not acknowledged our registration yet. + pending, + + /// A distributor is chosen and has acknowledged our registration. + ready, + + /// A distributor was chosen previously but is no longer installed. Web push + /// is dead in this state and there is no fallback transport. + unavailable, +} + +class PushDistributor { + final String packageName; + + /// Human-readable app label, or null if the package is no longer installed. + final String? label; + + PushDistributor({required this.packageName, required this.label}); +} + +class PushStatus { + final PushDistributorStatus status; + final PushDistributor? current; + final List available; + + /// Most recent distributor registration failure, or null if none. + /// + /// Held natively rather than delivered as a one-shot event: registrations are + /// attempted at startup and from background broadcasts, both of which can run + /// long before any Dart listener exists. + final String? lastError; + + PushStatus({ + required this.status, + required this.current, + required this.available, + required this.lastError, + }); +} + +class PushSubscription { + /// Subscription identifier, which for web push is the site's origin. + final String scope; + + /// Whether the distributor has handed back an endpoint for this scope. + final bool hasEndpoint; + + PushSubscription({required this.scope, required this.hasEndpoint}); +} + +/// Dart → Kotlin. UnifiedPush distributor management and web push introspection. +@HostApi() +abstract class GeckoPushApi { + @async + PushStatus getPushStatus(); + + /// Selects [packageName], which must be one of [PushStatus.available]. + /// + /// The picker is built in Dart rather than delegated to the connector's own + /// dialog, which would save the selection against a non-profile context. + @async + void setDistributor(String packageName); + + /// Forgets the current distributor. This is the off switch for web push. + @async + void removeDistributor(); + + @async + void renewRegistration(); + + /// Pauses push transport for the current profile before switching profiles. + /// Site subscriptions and the chosen distributor are retained for restoration + /// when this profile becomes active again. + @async + void suspendForProfileSwitch(String targetProfileId); + + /// Subscriptions Gecko has created, read from the UnifiedPush store. Read-only: + /// there is no app→Gecko channel to revoke a subscription, so removal has to go + /// through the site's notification permission instead. + @async + List getSubscriptions(); +} + +/// Kotlin → Dart. Push registration lifecycle. +/// +/// Registration failures reach Dart through [PushStatus.lastError] rather than a +/// dedicated event, so a failure raised before any Dart listener is attached is +/// still visible the first time the settings screen reads the status. +@FlutterApi() +abstract class GeckoPushEvents { + /// [sequence] Event sequence number for ordering. + void onPushStatusChanged(int sequence, PushStatus status); +} diff --git a/packages/flutter_mozilla_components/test/gecko_push_test.dart b/packages/flutter_mozilla_components/test/gecko_push_test.dart new file mode 100644 index 00000000..bbb66d1d --- /dev/null +++ b/packages/flutter_mozilla_components/test/gecko_push_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' + show GeckoPushEvents; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('accepts sequence zero and ignores duplicate or older events', () async { + final service = GeckoPushService(); + addTearDown(service.dispose); + final statuses = []; + final subscription = service.statusChanges.listen(statuses.add); + addTearDown(subscription.cancel); + + service.onPushStatusChanged(0, _status(PushDistributorStatus.pending)); + service.onPushStatusChanged(0, _status(PushDistributorStatus.ready)); + service.onPushStatusChanged(-1, _status(PushDistributorStatus.unavailable)); + service.onPushStatusChanged(2, _status(PushDistributorStatus.ready)); + await pumpEventQueue(); + + expect(statuses.map((status) => status.status), [ + PushDistributorStatus.pending, + PushDistributorStatus.ready, + ]); + }); + + test( + 'setup and disposal are idempotent and unregister the exact channel', + () async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final service = GeckoPushService( + binaryMessenger: messenger, + messageChannelSuffix: 'push-test', + ); + final statuses = []; + final subscription = service.statusChanges.listen(statuses.add); + addTearDown(subscription.cancel); + + service.setUp(); + service.setUp(); + + final responseBeforeDispose = await _dispatchStatus( + messenger, + suffix: 'push-test', + sequence: 1, + status: _status(PushDistributorStatus.ready), + ); + await pumpEventQueue(); + + expect(responseBeforeDispose, isNotNull); + expect(statuses, hasLength(1)); + + await service.dispose(); + await service.dispose(); + + final responseAfterDispose = await _dispatchStatus( + messenger, + suffix: 'push-test', + sequence: 2, + status: _status(PushDistributorStatus.unavailable), + ); + service.onPushStatusChanged(3, _status(PushDistributorStatus.pending)); + service.setUp(); + await pumpEventQueue(); + + expect(responseAfterDispose, isNull); + expect(statuses, hasLength(1)); + }, + ); +} + +PushStatus _status(PushDistributorStatus status) { + return PushStatus(status: status, available: const []); +} + +Future _dispatchStatus( + TestDefaultBinaryMessenger messenger, { + required String suffix, + required int sequence, + required PushStatus status, +}) async { + final reply = Completer(); + final channelSuffix = suffix.isEmpty ? '' : '.$suffix'; + await messenger.handlePlatformMessage( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPushEvents.onPushStatusChanged$channelSuffix', + GeckoPushEvents.pigeonChannelCodec.encodeMessage([sequence, status]), + reply.complete, + ); + return reply.future; +}