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(
int handle,
Pointer<

View file

@ -104,6 +104,42 @@ class NotificationController
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({
required int id,
required String title,

View file

@ -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<String?>(
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,

View file

@ -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<String?> {
},
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<String?> {
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<String?> {
state = .loading();
try {
final deviceIdCompleter = Completer<String>();
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<String?> {
}
}
Future<void> deregister([String? instance]) async {
final clientState = ref.read(ClientStateController.provider);
Future<bool> 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<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);
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);
}

View file

@ -4,53 +4,35 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({
required final T? initialValue,
required final T? value,
required final Icon icon,
required final String title,
required final List<T> 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<T>(
title: title,
getName: getName,
onChanged: onChanged,
value: value,
options: options,
),
child: ListTile(
enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
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,
),
),
);