From deac9ef24e5f2a1f0bc85f2a4570537078cda81f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 18 Oct 2026 16:36:36 -0400 Subject: [PATCH] handle permission denials more gracefully --- lib/controllers/gomuks_listener.dart | 13 +++++ lib/controllers/notification.dart | 36 ++++++++++++++ lib/controllers/settings_sections.dart | 37 ++++++++------ lib/controllers/unified_push.dart | 54 +++++++++++++-------- lib/widgets/settings/dialog_list_tile.dart | 56 ++++++++-------------- 5 files changed, 124 insertions(+), 72 deletions(-) diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index bcf873a..96a21db 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -143,6 +143,19 @@ class GomuksListenerController extends AsyncNotifier { } } + /// Waits for gomuks to report the client's device ID + Future deviceId() { + final completer = Completer(); + final subscription = ref.listen( + ClientStateController.provider.select((value) => value?.deviceId), + (_, next) { + if (next != null && !completer.isCompleted) completer.complete(next); + }, + fireImmediately: true, + ); + return completer.future.whenComplete(subscription.close); + } + static Future _start( int handle, Pointer< diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 7b5d2b8..2356c32 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -104,6 +104,42 @@ class NotificationController return true; } + /// Whether notifications can currently be shown, without prompting + Future permissionsGranted() async { + if (Platform.isLinux) { + return true; + } + + final controller = await future; + + if (Platform.isIOS) { + return (await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isMacOS) { + return (await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.areNotificationsEnabled() ?? + true; + } + + return true; + } + Future send({ required int id, required String title, diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index c21d7ef..1ca07a8 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -46,7 +46,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: settings.theme, + value: settings.theme, options: ThemeMode.values, getName: (option) => toBeginningOfSentenceCase(option.name), onChanged: (value) => ref @@ -105,7 +105,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: settings.roomOrder, + value: settings.roomOrder, options: RoomOrder.values, getName: (option) => switch (option) { .unreadFirst => "Mentioned, then unread", @@ -194,7 +194,6 @@ class SettingsSectionsController final current = pusherRegistered.value; return DialogListTile( - key: ValueKey(current), title: title, subtitle: Text( unifiedPushAllowed.maybeWhen( @@ -204,8 +203,7 @@ class SettingsSectionsController description, ), icon: Icon(icon), - required: false, - initialValue: current, + value: current, options: [ null, ...?distributors.value, @@ -227,21 +225,32 @@ class SettingsSectionsController try { loading.value = true; if (value != null) { - if (await ref + final messenger = ScaffoldMessenger.of( + context, + ); + if (!await ref .read( NotificationController .provider .notifier, ) .requestPermissions()) { - await ref - .read( - UnifiedPushController - .provider - .notifier, - ) - .register(value); + messenger.showSnackBar( + .new( + content: Text( + "Notifications are disabled. Enable them for Nexus in your system settings.", + ), + ), + ); + return; } + await ref + .read( + UnifiedPushController + .provider + .notifier, + ) + .register(value); } else { await ref .read( @@ -283,7 +292,7 @@ class SettingsSectionsController icon: Icon(icon), title: title, subtitle: Text(description), - initialValue: ref + value: ref .watch(AccountDataController.provider) .invitePermissionConfig .defaultAction, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 333ecc6..e862d8d 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -10,7 +10,7 @@ import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/push_key.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:window_manager/window_manager.dart"; @@ -72,6 +72,11 @@ class UnifiedPushController extends AsyncNotifier { }, onMessage: (message, instance) async { debugPrint("UP message received for $instance"); + if (await deregisterIfDisabled(instance)) { + if (isInBackground) exit(0); + return; + } + if (message.decrypted == false) { throw Exception( "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", @@ -135,9 +140,11 @@ class UnifiedPushController extends AsyncNotifier { onUnregistered: deregister, ); - if (!registered) return null; + if (!registered || isInBackground) return null; try { + if (await deregisterIfDisabled()) return null; + return await register(); } catch (error, stackTrace) { showError(error, stackTrace); @@ -156,19 +163,9 @@ class UnifiedPushController extends AsyncNotifier { state = .loading(); try { - final deviceIdCompleter = Completer(); - final subscription = ref.listen( - ClientStateController.provider.select((value) => value?.deviceId), - (_, next) { - if (next != null && !deviceIdCompleter.isCompleted) { - deviceIdCompleter.complete(next); - } - }, - fireImmediately: true, - ); - final deviceId = await deviceIdCompleter.future.whenComplete( - subscription.close, - ); + final deviceId = await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); await Future(() async { final capabilities = await ref @@ -214,12 +211,27 @@ class UnifiedPushController extends AsyncNotifier { } } - Future deregister([String? instance]) async { - final clientState = ref.read(ClientStateController.provider); + Future deregisterIfDisabled([String? notificationInstance]) async { + if (await ref + .read(NotificationController.provider.notifier) + .permissionsGranted()) { + return false; + } - final keyProvider = PushKeyController.provider( - instance ?? clientState!.deviceId!, - ); + final instance = + notificationInstance ?? + await ref.read(GomuksListenerController.provider.notifier).deviceId(); + final key = await ref.read(PushKeyController.provider(instance).future); + if (key != null) await deregister(instance); + return true; + } + + Future deregister([String? instance]) async { + instance ??= await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + + final keyProvider = PushKeyController.provider(instance); final key = await ref.read(keyProvider.future); if (key != null) { @@ -233,7 +245,7 @@ class UnifiedPushController extends AsyncNotifier { ); } - await UnifiedPush.unregister(instance ?? clientState!.deviceId!); + await UnifiedPush.unregister(instance); state = .data(null); } diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index afa79f3..d802d7b 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -4,53 +4,35 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/widgets/settings/radio_sheet.dart"; final class const DialogListTile({ - required final T? initialValue, + required final T? value, required final Icon icon, required final String title, required final List options, required final void Function(T value)? onChanged, required final String Function(T option) getName, final Widget? subtitle, - final bool required = true, super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => FormField( - validator: (value) => - value == null && required == true ? "This field is required." : null, - initialValue: initialValue, - builder: (field) => InputDecorator( - decoration: InputDecoration( - errorText: field.errorText, - contentPadding: EdgeInsets.zero, - enabledBorder: InputBorder.none, + Widget build(BuildContext context, WidgetRef ref) => ListTile( + enabled: onChanged != null, + onTap: () => context.showAdaptiveSheet( + (context) => RadioSheet( + title: title, + getName: getName, + onChanged: onChanged, + value: value, + options: options, ), - child: ListTile( - enabled: onChanged != null, - onTap: () => context.showAdaptiveSheet( - (context) => RadioSheet( - title: title, - getName: getName, - onChanged: onChanged == null - ? null - : (value) { - field.didChange(value); - onChanged!.call(value); - }, - value: field.value, - options: options, - ), - ), - title: Text(title), - subtitle: subtitle, - leading: icon, - trailing: Chip( - label: Text( - field.value == null ? "None" : getName(field.value as T), - overflow: TextOverflow.ellipsis, - style: onChanged == null ? .new(color: Colors.grey) : null, - ), - ), + ), + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: Chip( + label: Text( + value == null ? "None" : getName(value as T), + overflow: TextOverflow.ellipsis, + style: onChanged == null ? .new(color: Colors.grey) : null, ), ), );