push feature stable
This commit is contained in:
@@ -75,7 +75,7 @@
|
|||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.receivers.UnifiedPushReceiver"
|
android:name="eu.weblibre.flutter_mozilla_components.push.UnifiedPushReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
|
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
|
||||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
|
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
|
||||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
|
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
|
||||||
|
<action android:name="org.unifiedpush.android.connector.TEMP_UNAVAILABLE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import android.content.SharedPreferences
|
|||||||
import eu.weblibre.flutter_mozilla_components.ActiveProfile
|
import eu.weblibre.flutter_mozilla_components.ActiveProfile
|
||||||
import eu.weblibre.flutter_mozilla_components.MegazordSetup
|
import eu.weblibre.flutter_mozilla_components.MegazordSetup
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||||
|
import eu.weblibre.flutter_mozilla_components.push.PushMessageScheduler
|
||||||
|
|
||||||
class MyApplication : Application() {
|
class MyApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -33,7 +34,7 @@ class MyApplication : Application() {
|
|||||||
|
|
||||||
// Resolve active profile EARLY so cold-start WorkManager workers
|
// Resolve active profile EARLY so cold-start WorkManager workers
|
||||||
// get profile-prefixed SharedPreferences
|
// get profile-prefixed SharedPreferences
|
||||||
ActiveProfile.resolveFromDisk(this)
|
ActiveProfile.resolveFromDisk(this)?.let(PushMessageScheduler::recoverLater)
|
||||||
|
|
||||||
// Rehydrate the sandbox capture registry from the on-disk JSON mirror
|
// Rehydrate the sandbox capture registry from the on-disk JSON mirror
|
||||||
// before Gecko has a chance to start restoring tabs. Each entry gets
|
// before Gecko has a chance to start restoring tabs. Each entry gets
|
||||||
|
|||||||
@@ -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_edit.dart';
|
||||||
import 'package:weblibre/features/web_feed/presentation/screens/feed_list.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_feed/presentation/select_feed_dialog.dart';
|
||||||
|
import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
|
||||||
|
|
||||||
part 'routes.bangs.dart';
|
part 'routes.bangs.dart';
|
||||||
part 'routes.bookmarks.dart';
|
part 'routes.bookmarks.dart';
|
||||||
|
|||||||
@@ -1592,6 +1592,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
|||||||
name: 'ExperimentalSettingsRoute',
|
name: 'ExperimentalSettingsRoute',
|
||||||
factory: $ExperimentalSettingsRoute._fromState,
|
factory: $ExperimentalSettingsRoute._fromState,
|
||||||
),
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'push',
|
||||||
|
name: 'WebPushSettingsRoute',
|
||||||
|
factory: $WebPushSettingsRoute._fromState,
|
||||||
|
),
|
||||||
GoRouteData.$route(
|
GoRouteData.$route(
|
||||||
path: 'bang',
|
path: 'bang',
|
||||||
name: 'BangSettingsRoute',
|
name: 'BangSettingsRoute',
|
||||||
@@ -1949,6 +1954,27 @@ mixin $ExperimentalSettingsRoute on GoRouteData {
|
|||||||
void replace(BuildContext context) => context.replace(location);
|
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<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
mixin $BangSettingsRoute on GoRouteData {
|
mixin $BangSettingsRoute on GoRouteData {
|
||||||
static BangSettingsRoute _fromState(GoRouterState state) =>
|
static BangSettingsRoute _fromState(GoRouterState state) =>
|
||||||
BangSettingsRoute();
|
BangSettingsRoute();
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ part of 'routes.dart';
|
|||||||
name: 'ExperimentalSettingsRoute',
|
name: 'ExperimentalSettingsRoute',
|
||||||
path: 'experimental',
|
path: 'experimental',
|
||||||
),
|
),
|
||||||
|
TypedGoRoute<WebPushSettingsRoute>(
|
||||||
|
name: 'WebPushSettingsRoute',
|
||||||
|
path: 'push',
|
||||||
|
),
|
||||||
TypedGoRoute<BangSettingsRoute>(name: 'BangSettingsRoute', path: 'bang'),
|
TypedGoRoute<BangSettingsRoute>(name: 'BangSettingsRoute', path: 'bang'),
|
||||||
TypedGoRoute<WebEngineHardeningRoute>(
|
TypedGoRoute<WebEngineHardeningRoute>(
|
||||||
name: 'WebEngineHardeningRoute',
|
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 {
|
class BangSettingsRoute extends GoRouteData with $BangSettingsRoute {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, GoRouterState state) {
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ final class EngineSuggestionsProvider
|
|||||||
EngineSuggestions create() => EngineSuggestions();
|
EngineSuggestions create() => EngineSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$engineSuggestionsHash() => r'10d4a8a53d184b6c1d107d8634a2f662dfd297f1';
|
String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f';
|
||||||
|
|
||||||
abstract class _$EngineSuggestions
|
abstract class _$EngineSuggestions
|
||||||
extends $StreamNotifier<List<GeckoSuggestion>> {
|
extends $StreamNotifier<List<GeckoSuggestion>> {
|
||||||
|
|||||||
+1
-47
@@ -19,7 +19,6 @@
|
|||||||
*/
|
*/
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.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/presentation/dialogs/quit_browser_dialog.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||||
import 'package:weblibre/utils/exit_app.dart';
|
import 'package:weblibre/utils/exit_app.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
|
||||||
|
|
||||||
const List<SettingsSectionDefinition> experimentalSettingsSections = [
|
const List<SettingsSectionDefinition> 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(
|
SettingsSectionDefinition(
|
||||||
title: 'Runtime & Startup',
|
title: 'Runtime & Startup',
|
||||||
entries: [
|
entries: [
|
||||||
@@ -67,46 +54,13 @@ class ExperimentalSettingsScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const SettingsDetailScaffold(
|
return const SettingsDetailScaffold(
|
||||||
title: 'Experimental',
|
title: 'Experimental',
|
||||||
subtitle: 'Push delivery, runtime isolation, and startup behavior.',
|
subtitle: 'Runtime isolation and startup behavior.',
|
||||||
icon: MdiIcons.flaskOutline,
|
icon: MdiIcons.flaskOutline,
|
||||||
sections: experimentalSettingsSections,
|
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 {
|
class _IsolatedProcessEnabledTile extends HookConsumerWidget {
|
||||||
const _IsolatedProcessEnabledTile();
|
const _IsolatedProcessEnabledTile();
|
||||||
|
|
||||||
|
|||||||
@@ -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/screens/web_content_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.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/settings/presentation/widgets/toolbar_layout_content.dart';
|
||||||
|
import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
|
||||||
|
|
||||||
class SettingsScreen extends HookWidget {
|
class SettingsScreen extends HookWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -135,6 +136,14 @@ _CategoryGroups _buildCategories() {
|
|||||||
sections: webContentSettingsSections,
|
sections: webContentSettingsSections,
|
||||||
onTap: (context) => WebContentSettingsRoute().push(context),
|
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(
|
_SettingsCategoryDefinition(
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
subtitle: 'Providers, bangs, search history',
|
subtitle: 'Providers, bangs, search history',
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ Future<bool?> showSwitchProfileDialog(
|
|||||||
icon: const Icon(Icons.warning),
|
icon: const Icon(Icons.warning),
|
||||||
title: const Text('Switch User'),
|
title: const Text('Switch User'),
|
||||||
content: Text(
|
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),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
+10
-3
@@ -55,9 +55,16 @@ Future<void> handleSwitchProfile(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldSwitch == true) {
|
if (shouldSwitch == true) {
|
||||||
await ref
|
try {
|
||||||
.read(profileRepositoryProvider.notifier)
|
await ref
|
||||||
.switchProfile(profile.id);
|
.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);
|
await exitApp(ref.container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:weblibre/core/filesystem.dart';
|
import 'package:weblibre/core/filesystem.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/domain/entities/profile.dart';
|
import 'package:weblibre/domain/entities/profile.dart';
|
||||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||||
|
import 'package:weblibre/features/web_push/domain/providers.dart';
|
||||||
|
|
||||||
part 'profile.g.dart';
|
part 'profile.g.dart';
|
||||||
|
|
||||||
@@ -37,7 +39,23 @@ class ProfileRepository extends _$ProfileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> switchProfile(String id) async {
|
Future<void> 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<Profile> createProfile({
|
Future<Profile> createProfile({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
|
|||||||
ProfileRepository create() => ProfileRepository();
|
ProfileRepository create() => ProfileRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d';
|
String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
|
||||||
|
|
||||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||||
FutureOr<List<Profile>> build();
|
FutureOr<List<Profile>> build();
|
||||||
|
|||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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> pushStatus(Ref ref) {
|
||||||
|
final service = ref.watch(pushServiceProvider);
|
||||||
|
|
||||||
|
final controller = StreamController<PushStatus>();
|
||||||
|
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<Object>((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<List<PushSubscription>> 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<void>? _operation;
|
||||||
|
String? _operationKey;
|
||||||
|
int _operationToken = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> build() async {}
|
||||||
|
|
||||||
|
Future<void> setDistributor(String packageName) {
|
||||||
|
return _mutate(
|
||||||
|
'set:$packageName',
|
||||||
|
() => ref.read(pushServiceProvider).setDistributor(packageName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _mutate(String key, Future<void> 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<void> _runMutation(
|
||||||
|
Future<void>? previous,
|
||||||
|
Future<void> 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<bool> isGranted() => Permission.notification.isGranted;
|
||||||
|
|
||||||
|
Future<PermissionStatus> request() => Permission.notification.request();
|
||||||
|
|
||||||
|
Future<bool> 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<bool> notificationPermissionGranted(Ref ref) {
|
||||||
|
return ref.watch(notificationPermissionServiceProvider).isGranted();
|
||||||
|
}
|
||||||
@@ -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<GeckoPushService> {
|
||||||
|
PushServiceProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'pushServiceProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$pushServiceHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<GeckoPushService> $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<GeckoPushService>(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>,
|
||||||
|
PushStatus,
|
||||||
|
Stream<PushStatus>
|
||||||
|
>
|
||||||
|
with $FutureModifier<PushStatus>, $StreamProvider<PushStatus> {
|
||||||
|
/// 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<PushStatus> $createElement($ProviderPointer pointer) =>
|
||||||
|
$StreamProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<PushStatus> 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<PushSubscription>>,
|
||||||
|
List<PushSubscription>,
|
||||||
|
FutureOr<List<PushSubscription>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<PushSubscription>>,
|
||||||
|
$FutureProvider<List<PushSubscription>> {
|
||||||
|
/// 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<List<PushSubscription>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<PushSubscription>> create(Ref ref) {
|
||||||
|
return pushSubscriptions(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$pushSubscriptionsHash() => r'bebbaf964fbd48ddf56cf69af4a1d44a74e0f288';
|
||||||
|
|
||||||
|
@ProviderFor(PushDistributorMutation)
|
||||||
|
final pushDistributorMutationProvider = PushDistributorMutationProvider._();
|
||||||
|
|
||||||
|
final class PushDistributorMutationProvider
|
||||||
|
extends $AsyncNotifierProvider<PushDistributorMutation, void> {
|
||||||
|
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<void> {
|
||||||
|
FutureOr<void> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<void>, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<void>, void>,
|
||||||
|
AsyncValue<void>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(notificationPermissionService)
|
||||||
|
final notificationPermissionServiceProvider =
|
||||||
|
NotificationPermissionServiceProvider._();
|
||||||
|
|
||||||
|
final class NotificationPermissionServiceProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
NotificationPermissionService,
|
||||||
|
NotificationPermissionService,
|
||||||
|
NotificationPermissionService
|
||||||
|
>
|
||||||
|
with $Provider<NotificationPermissionService> {
|
||||||
|
NotificationPermissionServiceProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'notificationPermissionServiceProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$notificationPermissionServiceHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<NotificationPermissionService> $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<NotificationPermissionService>(
|
||||||
|
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<AsyncValue<bool>, bool, FutureOr<bool>>
|
||||||
|
with $FutureModifier<bool>, $FutureProvider<bool> {
|
||||||
|
/// 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<bool> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> create(Ref ref) {
|
||||||
|
return notificationPermissionGranted(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$notificationPermissionGrantedHash() =>
|
||||||
|
r'3344fb6996f7577cddb5c1dc24ae262f2724750e';
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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<SettingsSectionDefinition> 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<void> _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<PushDistributor>(
|
||||||
|
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<void> _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.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <AsyncValue<PushStatus>>[];
|
||||||
|
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 = <AsyncValue<PushStatus>>[];
|
||||||
|
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<void>();
|
||||||
|
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<StateError>()));
|
||||||
|
await expectLater(duplicate, throwsA(isA<StateError>()));
|
||||||
|
|
||||||
|
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<StateError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<void>();
|
||||||
|
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<PushStatus>.broadcast(sync: true);
|
||||||
|
final initialStatus = Completer<PushStatus>();
|
||||||
|
Future<void> setDistributorResult = Future.value();
|
||||||
|
Object? removeDistributorError;
|
||||||
|
int setDistributorCalls = 0;
|
||||||
|
int removeDistributorCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<PushStatus> get statusChanges => statusController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushStatus> getPushStatus() => initialStatus.future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setDistributor(String packageName) {
|
||||||
|
setDistributorCalls++;
|
||||||
|
return setDistributorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeDistributor() async {
|
||||||
|
removeDistributorCalls++;
|
||||||
|
if (removeDistributorError case final error?) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void emit(PushStatus status) => statusController.add(status);
|
||||||
|
|
||||||
|
Future<void> close() => statusController.close();
|
||||||
|
}
|
||||||
+148
@@ -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<PermissionStatus>();
|
||||||
|
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<void> _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<PushStatus> get statusChanges => const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PushStatus> getPushStatus() async {
|
||||||
|
return PushStatus(
|
||||||
|
status: PushDistributorStatus.notSelected,
|
||||||
|
available: [
|
||||||
|
PushDistributor(
|
||||||
|
packageName: 'org.example.distributor',
|
||||||
|
label: 'Test Distributor',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PushSubscription>> getSubscriptions() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setDistributor(String packageName) async {
|
||||||
|
setDistributorCalls++;
|
||||||
|
if (setDistributorError case final error?) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakePermissionService extends NotificationPermissionService {
|
||||||
|
final Future<PermissionStatus>? requestResult;
|
||||||
|
int checkCalls = 0;
|
||||||
|
int requestCalls = 0;
|
||||||
|
|
||||||
|
_FakePermissionService({this.requestResult});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isGranted() async {
|
||||||
|
checkCalls++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PermissionStatus> request() {
|
||||||
|
requestCalls++;
|
||||||
|
return requestResult ?? Future.value(PermissionStatus.denied);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@ dependencies {
|
|||||||
//https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo
|
//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.activity:activity-ktx:1.13.0'
|
||||||
implementation 'androidx.paging:paging-runtime-ktx:3.5.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.jetbrains.kotlin:kotlin-test")
|
||||||
testImplementation("org.mockito:mockito-core:5.23.0")
|
testImplementation("org.mockito:mockito-core:5.23.0")
|
||||||
|
|||||||
+47
-3
@@ -20,7 +20,12 @@
|
|||||||
package eu.weblibre.flutter_mozilla_components
|
package eu.weblibre.flutter_mozilla_components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.AtomicFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
object ActiveProfile {
|
object ActiveProfile {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -44,11 +49,50 @@ object ActiveProfile {
|
|||||||
* Resolve the active profile prefix from disk.
|
* Resolve the active profile prefix from disk.
|
||||||
* Called in Application.onCreate() to handle cold-start WorkManager scenarios.
|
* 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)
|
val profileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE)
|
||||||
if (!profileFile.exists()) return
|
val uuid = try {
|
||||||
val uuid = profileFile.readText().trim().ifEmpty { return }
|
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"
|
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
|
prefix = File(relativePath).name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prevent profile switches from crossing active-profile background work. */
|
||||||
|
internal suspend fun <T> withProfileLock(block: suspend () -> T): T =
|
||||||
|
profileMutex.withLock { block() }
|
||||||
|
|
||||||
|
private val profileMutex = Mutex()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -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.BackgroundServices
|
||||||
import eu.weblibre.flutter_mozilla_components.components.Events
|
import eu.weblibre.flutter_mozilla_components.components.Events
|
||||||
import eu.weblibre.flutter_mozilla_components.components.Features
|
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.Search
|
||||||
import eu.weblibre.flutter_mozilla_components.components.Services
|
import eu.weblibre.flutter_mozilla_components.components.Services
|
||||||
import eu.weblibre.flutter_mozilla_components.components.UseCases
|
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 features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
|
||||||
val search by lazy { Search(profileApplicationContext, core, useCases) }
|
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 mainBrowserEngineView: EngineView? = null
|
||||||
var externalAppEngineView: EngineView? = null
|
var externalAppEngineView: EngineView? = null
|
||||||
|
|||||||
+6
@@ -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.feature.SandboxCaptureFeature
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi
|
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.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
@@ -43,7 +44,12 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
|||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
|
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
|
||||||
|
GeckoPushApi.setUp(binding.binaryMessenger, null)
|
||||||
|
browserApi.disposePushApi()
|
||||||
GlobalComponents.historyEvents = null
|
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) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
|||||||
+68
@@ -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.GeckoEngineSettings
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
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.GeckoSelectionActionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
|
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.api.GeckoEngineSettingsApiImpl
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
|
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge
|
import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge
|
||||||
|
import eu.weblibre.flutter_mozilla_components.push.Push
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider
|
import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider
|
||||||
import mozilla.components.browser.session.storage.RecoverableBrowserState
|
import mozilla.components.browser.session.storage.RecoverableBrowserState
|
||||||
import mozilla.components.browser.state.action.RestoreCompleteAction
|
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 UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists"
|
||||||
private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF =
|
private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF =
|
||||||
"browser.weblibre.excludedHistoryContextIds"
|
"browser.weblibre.excludedHistoryContextIds"
|
||||||
|
private const val PROFILE_SWITCH_PERSIST_TIMEOUT_MS = 3000L
|
||||||
|
private const val PROFILE_SWITCH_DETACH_TIMEOUT_MS = 2000L
|
||||||
|
|
||||||
object GlobalComponents {
|
object GlobalComponents {
|
||||||
private var _components: Components? = null
|
private var _components: Components? = null
|
||||||
@@ -74,6 +79,30 @@ object GlobalComponents {
|
|||||||
val components: Components?
|
val components: Components?
|
||||||
get() = _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 {
|
enum class ComponentsMode {
|
||||||
FULL,
|
FULL,
|
||||||
EXTERNAL,
|
EXTERNAL,
|
||||||
@@ -116,6 +145,11 @@ object GlobalComponents {
|
|||||||
// container contextIds but skips Dart relation emits.
|
// container contextIds but skips Dart relation emits.
|
||||||
var historyEvents: GeckoHistoryEvents? = null
|
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.
|
// Gecko contextIds of containers with hard exclude-from-history enabled.
|
||||||
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
|
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
|
||||||
// write for visits resolved to one of these containers.
|
// write for visits resolved to one of these containers.
|
||||||
@@ -334,6 +368,40 @@ object GlobalComponents {
|
|||||||
emptyList()
|
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(
|
val newComponents = Components(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
flutterEvents,
|
flutterEvents,
|
||||||
|
|||||||
+3
@@ -12,6 +12,9 @@ import java.io.File
|
|||||||
class ProfileContext(private val base: Context, val relativePath: String) :
|
class ProfileContext(private val base: Context, val relativePath: String) :
|
||||||
ContextWrapper(base) {
|
ContextWrapper(base) {
|
||||||
|
|
||||||
|
internal val rootApplicationContext: Context
|
||||||
|
get() = base.applicationContext
|
||||||
|
|
||||||
private val subfolderRoot =
|
private val subfolderRoot =
|
||||||
File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default
|
File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default
|
||||||
|
|
||||||
|
|||||||
+25
-17
@@ -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.GeckoLogging
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
|
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.GeckoPwaApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||||
@@ -132,6 +134,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
|
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
private var isPlatformViewRegistered = false
|
private var isPlatformViewRegistered = false
|
||||||
|
private var pushApi: GeckoPushApiImpl? = null
|
||||||
|
|
||||||
private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
|
private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
|
||||||
private lateinit var _flutterEvents: GeckoStateEvents
|
private lateinit var _flutterEvents: GeckoStateEvents
|
||||||
@@ -167,6 +170,11 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
this.activity = activity
|
this.activity = activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun disposePushApi() {
|
||||||
|
pushApi?.dispose()
|
||||||
|
pushApi = null
|
||||||
|
}
|
||||||
|
|
||||||
fun detachActivity() {
|
fun detachActivity() {
|
||||||
this.activity = null
|
this.activity = null
|
||||||
}
|
}
|
||||||
@@ -272,6 +280,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GlobalComponents.historyEvents =
|
GlobalComponents.historyEvents =
|
||||||
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
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(
|
GlobalComponents.setUp(
|
||||||
profileApplicationContext,
|
profileApplicationContext,
|
||||||
_flutterEvents,
|
_flutterEvents,
|
||||||
@@ -362,6 +374,15 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GeckoGestureApiImpl()
|
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(
|
ReaderViewEvents.setUp(
|
||||||
_flutterPluginBinding.binaryMessenger,
|
_flutterPluginBinding.binaryMessenger,
|
||||||
components.events.readerViewEvents
|
components.events.readerViewEvents
|
||||||
@@ -496,23 +517,6 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
currentActivity.startActivity(intent)
|
currentActivity.startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pickUnifiedPushDistributor(callback: (Result<Boolean>) -> 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() {
|
override fun shutdown() {
|
||||||
logger.debug("$TAG: Shutting down GeckoView engine")
|
logger.debug("$TAG: Shutting down GeckoView engine")
|
||||||
|
|
||||||
@@ -538,6 +542,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
// 2. Stop component-level services
|
// 2. Stop component-level services
|
||||||
try {
|
try {
|
||||||
GlobalComponents.stopPrivateTabsNotificationFeature()
|
GlobalComponents.stopPrivateTabsNotificationFeature()
|
||||||
|
disposePushApi()
|
||||||
|
GlobalComponents.closePush()
|
||||||
|
|
||||||
GlobalComponents.components?.let { components ->
|
GlobalComponents.components?.let { components ->
|
||||||
// Stop the FxA web channel feature
|
// Stop the FxA web channel feature
|
||||||
@@ -555,6 +561,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
EngineProvider.shutdown()
|
EngineProvider.shutdown()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("$TAG: Error shutting down GeckoRuntime", e)
|
logger.error("$TAG: Error shutting down GeckoRuntime", e)
|
||||||
|
} finally {
|
||||||
|
GlobalComponents.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
isGeckoInitialized = false
|
isGeckoInitialized = false
|
||||||
|
|||||||
+82
@@ -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<PushStatus>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) { push.status() }.toPigeon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setDistributor(packageName: String, callback: (Result<Unit>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) { push.setDistributor(packageName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeDistributor(callback: (Result<Unit>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) { push.removeDistributor() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun renewRegistration(callback: (Result<Unit>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) { push.renewRegistration() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun suspendForProfileSwitch(targetProfileId: String, callback: (Result<Unit>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) { push.suspendForProfileSwitch(targetProfileId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSubscriptions(callback: (Result<List<PushSubscription>>) -> Unit) {
|
||||||
|
respond(callback) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
push.subscriptions().map {
|
||||||
|
PushSubscription(scope = it.scope, hasEndpoint = it.hasEndpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> respond(callback: (Result<T>) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -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.middleware.SaveToPDFMiddleware
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||||
|
import eu.weblibre.flutter_mozilla_components.push.WebNotificationDrainCoordinator
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage
|
import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage
|
||||||
import mozilla.components.browser.engine.gecko.util.EngineDownloadDelegate
|
import mozilla.components.browser.engine.gecko.util.EngineDownloadDelegate
|
||||||
@@ -225,6 +226,11 @@ class Core(
|
|||||||
HistoryMetadataService(storage = historyStorage)
|
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)
|
@OptIn(FlowPreview::class)
|
||||||
val store by lazy {
|
val store by lazy {
|
||||||
BrowserStore(
|
BrowserStore(
|
||||||
@@ -282,7 +288,11 @@ class Core(
|
|||||||
|
|
||||||
icons.install(engine, this)
|
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,
|
context,
|
||||||
engine,
|
engine,
|
||||||
icons,
|
icons,
|
||||||
@@ -291,6 +301,8 @@ class Core(
|
|||||||
NotificationActivity::class.java,
|
NotificationActivity::class.java,
|
||||||
notificationsDelegate = components.notificationsDelegate,
|
notificationsDelegate = components.notificationsDelegate,
|
||||||
)
|
)
|
||||||
|
webNotificationDrainCoordinator.delegate = webNotificationFeature
|
||||||
|
engine.registerWebNotificationDelegate(webNotificationDrainCoordinator)
|
||||||
|
|
||||||
MediaSessionFeature(context, MediaSessionService::class.java, this).start()
|
MediaSessionFeature(context, MediaSessionService::class.java, this).start()
|
||||||
}
|
}
|
||||||
|
|||||||
-53
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+844
-191
File diff suppressed because it is too large
Load Diff
+473
@@ -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<Unit>()
|
||||||
|
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<DistributorInfo>,
|
||||||
|
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<PushSubscriptionInfo> = 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<Unit>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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<PushMessageWorker>()
|
||||||
|
.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<String>()
|
||||||
|
private val recoveryExecutor = Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||||
|
Thread(runnable, "PushMessageRecovery").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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<String> {
|
||||||
|
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_-]+")
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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
|
||||||
|
}
|
||||||
+72
@@ -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)
|
||||||
|
}
|
||||||
+173
@@ -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<String?>()
|
||||||
|
private val currentMessageId = ThreadLocal<String?>()
|
||||||
|
|
||||||
|
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 <T> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -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<Unit>? = null
|
||||||
|
private var waitOrigin: String? = null
|
||||||
|
|
||||||
|
override fun onShowNotification(webNotification: WebNotification): Deferred<Boolean> {
|
||||||
|
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<Unit>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -6,8 +6,11 @@ package eu.weblibre.flutter_mozilla_components.push
|
|||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mozilla.components.concept.engine.Engine
|
import mozilla.components.concept.engine.Engine
|
||||||
import mozilla.components.concept.engine.webpush.WebPushDelegate
|
import mozilla.components.concept.engine.webpush.WebPushDelegate
|
||||||
import mozilla.components.concept.engine.webpush.WebPushHandler
|
import mozilla.components.concept.engine.webpush.WebPushHandler
|
||||||
@@ -44,6 +47,25 @@ class WebPushEngineIntegration(
|
|||||||
pushFeature.unregister(this)
|
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?) {
|
override fun onMessageReceived(scope: PushScope, message: ByteArray?) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
handler?.onPushMessage(scope, message)
|
handler?.onPushMessage(scope, message)
|
||||||
|
|||||||
-75
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-21
@@ -1,27 +1,11 @@
|
|||||||
package eu.weblibre.flutter_mozilla_components
|
package eu.weblibre.flutter_mozilla_components
|
||||||
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import org.mockito.Mockito
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
internal class FlutterMozillaContextPluginTest {
|
internal class FlutterMozillaContextPluginTest {
|
||||||
@Test
|
@Test
|
||||||
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
|
fun pluginCanBeConstructed() {
|
||||||
val plugin = FlutterMozillaComponentsPlugin()
|
assertNotNull(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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val secondEntered = CompletableDeferred<Unit>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -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<IllegalArgumentException> {
|
||||||
|
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<CorruptPushMessageException> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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<Unit>()
|
||||||
|
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<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val secondEntered = CompletableDeferred<Unit>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export 'src/domain/services/gecko_icon.dart';
|
|||||||
export 'src/domain/services/gecko_logging.dart';
|
export 'src/domain/services/gecko_logging.dart';
|
||||||
export 'src/domain/services/gecko_ml.dart';
|
export 'src/domain/services/gecko_ml.dart';
|
||||||
export 'src/domain/services/gecko_pref.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_readerable.dart';
|
||||||
export 'src/domain/services/gecko_selection_action.dart';
|
export 'src/domain/services/gecko_selection_action.dart';
|
||||||
export 'src/domain/services/gecko_session.dart';
|
export 'src/domain/services/gecko_session.dart';
|
||||||
@@ -100,6 +101,10 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
MlProgressType,
|
MlProgressType,
|
||||||
PhoneHitResult,
|
PhoneHitResult,
|
||||||
ProxyLoadError,
|
ProxyLoadError,
|
||||||
|
PushDistributor,
|
||||||
|
PushDistributorStatus,
|
||||||
|
PushStatus,
|
||||||
|
PushSubscription,
|
||||||
PwaIcon,
|
PwaIcon,
|
||||||
PwaManifest,
|
PwaManifest,
|
||||||
QueryParameterStripping,
|
QueryParameterStripping,
|
||||||
|
|||||||
@@ -69,10 +69,6 @@ class GeckoBrowserService {
|
|||||||
return _api.requestDefaultBrowser();
|
return _api.requestDefaultBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> pickUnifiedPushDistributor() {
|
|
||||||
return _api.pickUnifiedPushDistributor();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> shutdown() {
|
Future<void> shutdown() {
|
||||||
return _api.shutdown();
|
return _api.shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PushStatus>();
|
||||||
|
BinaryMessenger? _eventBinaryMessenger;
|
||||||
|
String _eventMessageChannelSuffix = '';
|
||||||
|
int? _lastStatusSequence;
|
||||||
|
bool _isSetUp = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
Future<void>? _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<PushStatus> 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<PushStatus> getPushStatus() => _api.getPushStatus();
|
||||||
|
|
||||||
|
/// Selects [packageName], which must be one of [PushStatus.available].
|
||||||
|
Future<void> setDistributor(String packageName) =>
|
||||||
|
_api.setDistributor(packageName);
|
||||||
|
|
||||||
|
/// Forgets the current distributor, disabling web push delivery.
|
||||||
|
Future<void> removeDistributor() => _api.removeDistributor();
|
||||||
|
|
||||||
|
Future<void> renewRegistration() => _api.renewRegistration();
|
||||||
|
|
||||||
|
Future<void> suspendForProfileSwitch(String targetProfileId) =>
|
||||||
|
_api.suspendForProfileSwitch(targetProfileId);
|
||||||
|
|
||||||
|
Future<List<PushSubscription>> 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<void> dispose() {
|
||||||
|
return _disposeFuture ??= _dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dispose() async {
|
||||||
|
_disposed = true;
|
||||||
|
if (_isSetUp) {
|
||||||
|
GeckoPushEvents.setUp(
|
||||||
|
null,
|
||||||
|
binaryMessenger: _eventBinaryMessenger,
|
||||||
|
messageChannelSuffix: _eventMessageChannelSuffix,
|
||||||
|
);
|
||||||
|
_isSetUp = false;
|
||||||
|
}
|
||||||
|
await _statusSubject.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1417,8 +1417,6 @@ abstract class GeckoBrowserApi {
|
|||||||
});
|
});
|
||||||
bool isDefaultBrowser();
|
bool isDefaultBrowser();
|
||||||
void requestDefaultBrowser();
|
void requestDefaultBrowser();
|
||||||
@async
|
|
||||||
bool pickUnifiedPushDistributor();
|
|
||||||
void shutdown();
|
void shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3146,3 +3144,105 @@ abstract class GeckoGestureEvents {
|
|||||||
/// [sequence] Event sequence number for ordering.
|
/// [sequence] Event sequence number for ordering.
|
||||||
void onGestureReset(int sequence);
|
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<PushDistributor> 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<PushSubscription> 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = <PushStatus>[];
|
||||||
|
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 = <PushStatus>[];
|
||||||
|
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<ByteData?> _dispatchStatus(
|
||||||
|
TestDefaultBinaryMessenger messenger, {
|
||||||
|
required String suffix,
|
||||||
|
required int sequence,
|
||||||
|
required PushStatus status,
|
||||||
|
}) async {
|
||||||
|
final reply = Completer<ByteData?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user