push feature stable
This commit is contained in:
@@ -75,7 +75,7 @@
|
||||
tools:node="remove" />
|
||||
|
||||
<receiver
|
||||
android:name="eu.weblibre.flutter_mozilla_components.receivers.UnifiedPushReceiver"
|
||||
android:name="eu.weblibre.flutter_mozilla_components.push.UnifiedPushReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -83,6 +83,7 @@
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
|
||||
<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.TEMP_UNAVAILABLE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.SharedPreferences
|
||||
import eu.weblibre.flutter_mozilla_components.ActiveProfile
|
||||
import eu.weblibre.flutter_mozilla_components.MegazordSetup
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||
import eu.weblibre.flutter_mozilla_components.push.PushMessageScheduler
|
||||
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
@@ -33,7 +34,7 @@ class MyApplication : Application() {
|
||||
|
||||
// Resolve active profile EARLY so cold-start WorkManager workers
|
||||
// get profile-prefixed SharedPreferences
|
||||
ActiveProfile.resolveFromDisk(this)
|
||||
ActiveProfile.resolveFromDisk(this)?.let(PushMessageScheduler::recoverLater)
|
||||
|
||||
// Rehydrate the sandbox capture registry from the on-disk JSON mirror
|
||||
// before Gecko has a chance to start restoring tabs. Each entry gets
|
||||
|
||||
@@ -111,6 +111,7 @@ import 'package:weblibre/features/web_feed/presentation/screens/feed_article_lis
|
||||
import 'package:weblibre/features/web_feed/presentation/screens/feed_edit.dart';
|
||||
import 'package:weblibre/features/web_feed/presentation/screens/feed_list.dart';
|
||||
import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart';
|
||||
import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
|
||||
|
||||
part 'routes.bangs.dart';
|
||||
part 'routes.bookmarks.dart';
|
||||
|
||||
@@ -1592,6 +1592,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
||||
name: 'ExperimentalSettingsRoute',
|
||||
factory: $ExperimentalSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'push',
|
||||
name: 'WebPushSettingsRoute',
|
||||
factory: $WebPushSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'bang',
|
||||
name: 'BangSettingsRoute',
|
||||
@@ -1949,6 +1954,27 @@ mixin $ExperimentalSettingsRoute on GoRouteData {
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $WebPushSettingsRoute on GoRouteData {
|
||||
static WebPushSettingsRoute _fromState(GoRouterState state) =>
|
||||
WebPushSettingsRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/settings/push');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<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 {
|
||||
static BangSettingsRoute _fromState(GoRouterState state) =>
|
||||
BangSettingsRoute();
|
||||
|
||||
@@ -63,6 +63,10 @@ part of 'routes.dart';
|
||||
name: 'ExperimentalSettingsRoute',
|
||||
path: 'experimental',
|
||||
),
|
||||
TypedGoRoute<WebPushSettingsRoute>(
|
||||
name: 'WebPushSettingsRoute',
|
||||
path: 'push',
|
||||
),
|
||||
TypedGoRoute<BangSettingsRoute>(name: 'BangSettingsRoute', path: 'bang'),
|
||||
TypedGoRoute<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 {
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ final class EngineSuggestionsProvider
|
||||
EngineSuggestions create() => EngineSuggestions();
|
||||
}
|
||||
|
||||
String _$engineSuggestionsHash() => r'10d4a8a53d184b6c1d107d8634a2f662dfd297f1';
|
||||
String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f';
|
||||
|
||||
abstract class _$EngineSuggestions
|
||||
extends $StreamNotifier<List<GeckoSuggestion>> {
|
||||
|
||||
+1
-47
@@ -19,7 +19,6 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
@@ -27,20 +26,8 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
const List<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(
|
||||
title: 'Runtime & Startup',
|
||||
entries: [
|
||||
@@ -67,46 +54,13 @@ class ExperimentalSettingsScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Experimental',
|
||||
subtitle: 'Push delivery, runtime isolation, and startup behavior.',
|
||||
subtitle: 'Runtime isolation and startup behavior.',
|
||||
icon: MdiIcons.flaskOutline,
|
||||
sections: experimentalSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnifiedPushDistributorTile extends StatelessWidget {
|
||||
const _UnifiedPushDistributorTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.bellBadgeOutline),
|
||||
title: const Text('Choose UnifiedPush Distributor'),
|
||||
subtitle: const Text(
|
||||
'Select the app that should deliver website push notifications to WebLibre.',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final success = await GeckoBrowserService()
|
||||
.pickUnifiedPushDistributor();
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
showInfoMessage(context, 'UnifiedPush distributor configured.');
|
||||
} else {
|
||||
showErrorMessage(
|
||||
context,
|
||||
'Could not configure UnifiedPush. Install a distributor and try again.',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IsolatedProcessEnabledTile extends HookConsumerWidget {
|
||||
const _IsolatedProcessEnabledTile();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'package:weblibre/features/settings/presentation/screens/search_settings.
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
|
||||
import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
|
||||
|
||||
class SettingsScreen extends HookWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -135,6 +136,14 @@ _CategoryGroups _buildCategories() {
|
||||
sections: webContentSettingsSections,
|
||||
onTap: (context) => WebContentSettingsRoute().push(context),
|
||||
),
|
||||
_SettingsCategoryDefinition(
|
||||
title: 'Notifications',
|
||||
subtitle: 'Web push delivery, distributor, site subscriptions',
|
||||
icon: MdiIcons.bellBadgeOutline,
|
||||
keywords: const ['push', 'unifiedpush', 'ntfy', 'distributor'],
|
||||
sections: webPushSettingsSections,
|
||||
onTap: (context) => WebPushSettingsRoute().push(context),
|
||||
),
|
||||
_SettingsCategoryDefinition(
|
||||
title: 'Search',
|
||||
subtitle: 'Providers, bangs, search history',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ Future<bool?> showSwitchProfileDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Switch User'),
|
||||
content: Text(
|
||||
"Switching to User '$profileName' will require a restart of the Browser.\n\nPrivate tab data will be cleared on restart.",
|
||||
"Switching to User '$profileName' will require a restart of the Browser. Web notifications for the inactive profile will be paused.\n\nPrivate tab data will be cleared on restart.",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
|
||||
+10
-3
@@ -55,9 +55,16 @@ Future<void> handleSwitchProfile(
|
||||
);
|
||||
|
||||
if (shouldSwitch == true) {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.switchProfile(profile.id);
|
||||
try {
|
||||
await ref
|
||||
.read(profileRepositoryProvider.notifier)
|
||||
.switchProfile(profile.id);
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ui_helper.showErrorMessage(context, 'Could not switch profile: $error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await exitApp(ref.container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/domain/entities/profile.dart';
|
||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||
import 'package:weblibre/features/web_push/domain/providers.dart';
|
||||
|
||||
part 'profile.g.dart';
|
||||
|
||||
@@ -37,7 +39,23 @@ class ProfileRepository extends _$ProfileRepository {
|
||||
}
|
||||
|
||||
Future<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({
|
||||
|
||||
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
|
||||
ProfileRepository create() => ProfileRepository();
|
||||
}
|
||||
|
||||
String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d';
|
||||
String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
|
||||
|
||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user