handle permission denials more gracefully

This commit is contained in:
Henry Hiles 2026-10-18 16:36:36 -04:00
commit deac9ef24e
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 123 additions and 71 deletions

View file

@ -143,6 +143,19 @@ class GomuksListenerController extends AsyncNotifier<void> {
} }
} }
/// Waits for gomuks to report the client's device ID
Future<String> deviceId() {
final completer = Completer<String>();
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<int> _start( static Future<int> _start(
int handle, int handle,
Pointer< Pointer<

View file

@ -104,6 +104,42 @@ class NotificationController
return true; return true;
} }
/// Whether notifications can currently be shown, without prompting
Future<bool> 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<void> send({ Future<void> send({
required int id, required int id,
required String title, required String title,

View file

@ -46,7 +46,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
initialValue: settings.theme, value: settings.theme,
options: ThemeMode.values, options: ThemeMode.values,
getName: (option) => toBeginningOfSentenceCase(option.name), getName: (option) => toBeginningOfSentenceCase(option.name),
onChanged: (value) => ref onChanged: (value) => ref
@ -105,7 +105,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
initialValue: settings.roomOrder, value: settings.roomOrder,
options: RoomOrder.values, options: RoomOrder.values,
getName: (option) => switch (option) { getName: (option) => switch (option) {
.unreadFirst => "Mentioned, then unread", .unreadFirst => "Mentioned, then unread",
@ -194,7 +194,6 @@ class SettingsSectionsController
final current = pusherRegistered.value; final current = pusherRegistered.value;
return DialogListTile<String?>( return DialogListTile<String?>(
key: ValueKey(current),
title: title, title: title,
subtitle: Text( subtitle: Text(
unifiedPushAllowed.maybeWhen( unifiedPushAllowed.maybeWhen(
@ -204,8 +203,7 @@ class SettingsSectionsController
description, description,
), ),
icon: Icon(icon), icon: Icon(icon),
required: false, value: current,
initialValue: current,
options: [ options: [
null, null,
...?distributors.value, ...?distributors.value,
@ -227,13 +225,25 @@ class SettingsSectionsController
try { try {
loading.value = true; loading.value = true;
if (value != null) { if (value != null) {
if (await ref final messenger = ScaffoldMessenger.of(
context,
);
if (!await ref
.read( .read(
NotificationController NotificationController
.provider .provider
.notifier, .notifier,
) )
.requestPermissions()) { .requestPermissions()) {
messenger.showSnackBar(
.new(
content: Text(
"Notifications are disabled. Enable them for Nexus in your system settings.",
),
),
);
return;
}
await ref await ref
.read( .read(
UnifiedPushController UnifiedPushController
@ -241,7 +251,6 @@ class SettingsSectionsController
.notifier, .notifier,
) )
.register(value); .register(value);
}
} else { } else {
await ref await ref
.read( .read(
@ -283,7 +292,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
initialValue: ref value: ref
.watch(AccountDataController.provider) .watch(AccountDataController.provider)
.invitePermissionConfig .invitePermissionConfig
.defaultAction, .defaultAction,

View file

@ -10,7 +10,7 @@ import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/controllers/client.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/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
@ -72,6 +72,11 @@ class UnifiedPushController extends AsyncNotifier<String?> {
}, },
onMessage: (message, instance) async { onMessage: (message, instance) async {
debugPrint("UP message received for $instance"); debugPrint("UP message received for $instance");
if (await deregisterIfDisabled(instance)) {
if (isInBackground) exit(0);
return;
}
if (message.decrypted == false) { if (message.decrypted == false) {
throw Exception( throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
@ -135,9 +140,11 @@ class UnifiedPushController extends AsyncNotifier<String?> {
onUnregistered: deregister, onUnregistered: deregister,
); );
if (!registered) return null; if (!registered || isInBackground) return null;
try { try {
if (await deregisterIfDisabled()) return null;
return await register(); return await register();
} catch (error, stackTrace) { } catch (error, stackTrace) {
showError(error, stackTrace); showError(error, stackTrace);
@ -156,19 +163,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
state = .loading(); state = .loading();
try { try {
final deviceIdCompleter = Completer<String>(); final deviceId = await ref
final subscription = ref.listen( .read(GomuksListenerController.provider.notifier)
ClientStateController.provider.select((value) => value?.deviceId), .deviceId();
(_, next) {
if (next != null && !deviceIdCompleter.isCompleted) {
deviceIdCompleter.complete(next);
}
},
fireImmediately: true,
);
final deviceId = await deviceIdCompleter.future.whenComplete(
subscription.close,
);
await Future(() async { await Future(() async {
final capabilities = await ref final capabilities = await ref
@ -214,12 +211,27 @@ class UnifiedPushController extends AsyncNotifier<String?> {
} }
} }
Future<void> deregister([String? instance]) async { Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
final clientState = ref.read(ClientStateController.provider); if (await ref
.read(NotificationController.provider.notifier)
.permissionsGranted()) {
return false;
}
final keyProvider = PushKeyController.provider( final instance =
instance ?? clientState!.deviceId!, 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<void> deregister([String? instance]) async {
instance ??= await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
final keyProvider = PushKeyController.provider(instance);
final key = await ref.read(keyProvider.future); final key = await ref.read(keyProvider.future);
if (key != null) { if (key != null) {
@ -233,7 +245,7 @@ class UnifiedPushController extends AsyncNotifier<String?> {
); );
} }
await UnifiedPush.unregister(instance ?? clientState!.deviceId!); await UnifiedPush.unregister(instance);
state = .data(null); state = .data(null);
} }

View file

@ -4,40 +4,24 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart"; import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({ final class const DialogListTile<T>({
required final T? initialValue, required final T? value,
required final Icon icon, required final Icon icon,
required final String title, required final String title,
required final List<T> options, required final List<T> options,
required final void Function(T value)? onChanged, required final void Function(T value)? onChanged,
required final String Function(T option) getName, required final String Function(T option) getName,
final Widget? subtitle, final Widget? subtitle,
final bool required = true,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => FormField( Widget build(BuildContext context, WidgetRef ref) => ListTile(
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,
),
child: ListTile(
enabled: onChanged != null, enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet( onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>( (context) => RadioSheet<T>(
title: title, title: title,
getName: getName, getName: getName,
onChanged: onChanged == null onChanged: onChanged,
? null value: value,
: (value) {
field.didChange(value);
onChanged!.call(value);
},
value: field.value,
options: options, options: options,
), ),
), ),
@ -46,12 +30,10 @@ final class const DialogListTile<T>({
leading: icon, leading: icon,
trailing: Chip( trailing: Chip(
label: Text( label: Text(
field.value == null ? "None" : getName(field.value as T), value == null ? "None" : getName(value as T),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null, style: onChanged == null ? .new(color: Colors.grey) : null,
), ),
), ),
),
),
); );
} }