Push Notifications (#66)
Fixes #35. Things to do: - [x] Register with local distributor - [x] Register with homeserver - [x] Recieve messages - [x] Show notifications - [ ] Notifications page (could come later) Reviewed-on: #66
This commit is contained in:
parent
6e42902322
commit
ff9bf3bec4
30 changed files with 772 additions and 36 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import "dart:async";
|
||||
import "dart:ffi";
|
||||
import "dart:io";
|
||||
import "dart:isolate";
|
||||
|
|
@ -15,11 +16,13 @@ import "package:nexus/controllers/sync_status.dart";
|
|||
import "package:nexus/controllers/top_level_spaces.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/capabilities.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/models/open_graph_data.dart";
|
||||
import "package:nexus/models/paginate.dart";
|
||||
import "package:nexus/models/requests/deregister_pusher.dart";
|
||||
import "package:nexus/models/requests/download_media.dart";
|
||||
import "package:nexus/models/requests/get_event.dart";
|
||||
import "package:nexus/models/requests/get_related_events.dart";
|
||||
|
|
@ -31,6 +34,7 @@ import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
|||
import "package:nexus/models/requests/oauth/register_client.dart";
|
||||
import "package:nexus/models/requests/paginate.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
import "package:nexus/models/requests/register_pusher.dart";
|
||||
import "package:nexus/models/requests/report.dart";
|
||||
import "package:nexus/models/requests/send_event.dart";
|
||||
import "package:nexus/models/requests/send_message.dart";
|
||||
|
|
@ -156,18 +160,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
throw Exception("GomuksStart returned error code $errorCode");
|
||||
}
|
||||
|
||||
Future<dynamic> _sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic> data = const {},
|
||||
]) async {
|
||||
Future<dynamic> callGomuksMethod(
|
||||
Map<String, dynamic> data,
|
||||
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
|
||||
callback,
|
||||
) async {
|
||||
final bufferPointer = data.toGomuksBufferPtr();
|
||||
final handle = await future;
|
||||
final response = await Isolate.run(
|
||||
() => GomuksSubmitCommand(
|
||||
handle,
|
||||
command.toNativeUtf8().cast<Char>(),
|
||||
bufferPointer.ref,
|
||||
),
|
||||
() => callback(handle, bufferPointer.ref),
|
||||
);
|
||||
|
||||
calloc.free(bufferPointer);
|
||||
|
|
@ -180,6 +181,24 @@ class ClientController extends AsyncNotifier<int> {
|
|||
return json;
|
||||
}
|
||||
|
||||
Future<Event?> handlePush(Map<String, dynamic> data) async {
|
||||
final response = await callGomuksMethod(
|
||||
data,
|
||||
(handle, data) async => GomuksHandlePush(handle, data),
|
||||
);
|
||||
|
||||
return response == null ? null : .fromJson(response);
|
||||
}
|
||||
|
||||
Future<dynamic> _sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic> data = const {},
|
||||
]) => callGomuksMethod(
|
||||
data,
|
||||
(handle, data) async =>
|
||||
GomuksSubmitCommand(handle, command.toNativeUtf8().cast<Char>(), data),
|
||||
);
|
||||
|
||||
Future<void> redactEvent(RedactEventRequest report) =>
|
||||
_sendCommand("redact_event", report.toJson());
|
||||
|
||||
|
|
@ -281,6 +300,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||
_sendCommand("set_account_data", request.toJson());
|
||||
|
||||
Future<void> registerPusher(RegisterPusherRequest request) =>
|
||||
_sendCommand("register_homeserver_push", request.toJson());
|
||||
|
||||
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
|
||||
_sendCommand("register_homeserver_push", {
|
||||
...request.toJson(),
|
||||
"kind": null,
|
||||
});
|
||||
|
||||
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
||||
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
||||
|
||||
|
|
@ -318,6 +346,10 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||
.fromJson(await _sendCommand("get_versions"));
|
||||
|
||||
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
|
||||
(await _sendCommand("get_capabilities"))["capabilities"],
|
||||
);
|
||||
|
||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||
try {
|
||||
final response = await _sendCommand("discover_homeserver", {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import "package:nexus/controllers/shared_prefs.dart";
|
|||
class KeyController(final String key) extends Notifier<String?> {
|
||||
static const String spaceKey = "space";
|
||||
static const String roomKey = "room";
|
||||
static const String pushKeyKey = "pushKey";
|
||||
|
||||
@override
|
||||
String? build() =>
|
||||
|
|
|
|||
120
lib/controllers/notifications.dart
Normal file
120
lib/controllers/notifications.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||
// ignore: implementation_imports
|
||||
import "package:flutter_local_notifications_linux/src/model/hint.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/pages/notifications.dart";
|
||||
|
||||
class NotificationsController
|
||||
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
|
||||
@override
|
||||
Future<FlutterLocalNotificationsPlugin> build() async {
|
||||
final notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
final darwin = DarwinInitializationSettings();
|
||||
|
||||
await notifications.initialize(
|
||||
settings: .new(
|
||||
windows: .new(
|
||||
appName: "Nexus",
|
||||
appUserModelId: "nexus.federated.nexus",
|
||||
guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
|
||||
),
|
||||
android: .new("ic_launcher_foreground"),
|
||||
iOS: darwin,
|
||||
macOS: darwin,
|
||||
linux: .new(defaultActionName: "Open"),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
if (details.payload case final eventId?) {
|
||||
if (navigatorKey.currentContext case final context?) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotificationsPage(eventId: eventId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final controller = await future;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||
true;
|
||||
} else if (Platform.isMacOS) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||
true;
|
||||
} else if (Platform.isAndroid) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission() ??
|
||||
true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> send({
|
||||
required int id,
|
||||
required String title,
|
||||
String? body,
|
||||
File? icon,
|
||||
String? payload,
|
||||
}) async {
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: .new(
|
||||
"messages",
|
||||
"Messages",
|
||||
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
|
||||
),
|
||||
linux: .new(
|
||||
customHints: icon == null
|
||||
? null
|
||||
: [
|
||||
.new(
|
||||
name: "image-path",
|
||||
value: LinuxHintStringValue(icon.path),
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68)
|
||||
iOS: .new(),
|
||||
macOS: .new(),
|
||||
windows: .new(),
|
||||
);
|
||||
|
||||
final controller = await future;
|
||||
await controller.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<
|
||||
NotificationsController,
|
||||
FlutterLocalNotificationsPlugin
|
||||
>(NotificationsController.new);
|
||||
}
|
||||
30
lib/controllers/push_key.dart
Normal file
30
lib/controllers/push_key.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
|
||||
class PushKeyController(final String instance) extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => json.decode(
|
||||
ref.watch(KeyController.provider(KeyController.pushKeyKey)) ?? "{}",
|
||||
)[instance];
|
||||
|
||||
Future<void> set(String? value) async {
|
||||
final provider = KeyController.provider(KeyController.pushKeyKey);
|
||||
final notifier = ref.watch(provider.notifier);
|
||||
final prefs = IMap(json.decode(ref.watch(provider) ?? "{}"));
|
||||
|
||||
state = value;
|
||||
notifier.set(
|
||||
json.encode(
|
||||
prefs.add(instance, value).where((_, value) => value != null).unlock,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.family<PushKeyController, String?, String>(
|
||||
PushKeyController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/notifications.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/unified_push.dart";
|
||||
import "package:nexus/controllers/spec_versions.dart";
|
||||
import "package:nexus/controllers/unified_push_allowed.dart";
|
||||
import "package:nexus/models/account_data.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
|
@ -95,6 +99,90 @@ class SettingsSectionsController
|
|||
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
||||
"Account": .new([
|
||||
.new(title: "Profile", icon: Icons.person, settings: .new([])),
|
||||
.new(
|
||||
title: "Notifications",
|
||||
icon: Icons.notifications,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Push notifications",
|
||||
description: "Enable push notifications using Web Push",
|
||||
builder: (title, description, icon) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final loading = useState(false);
|
||||
final pusherRegistered = ref.watch(
|
||||
UnifiedPushController.provider,
|
||||
);
|
||||
final unifiedPushAllowed = ref.watch(
|
||||
UnifiedPushAllowedController.provider,
|
||||
);
|
||||
|
||||
return SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
unifiedPushAllowed.maybeWhen(
|
||||
data: (data) => data,
|
||||
orElse: () => null,
|
||||
) ??
|
||||
description,
|
||||
),
|
||||
secondary: Icon(icon),
|
||||
value: pusherRegistered.maybeWhen(
|
||||
data: (value) => value,
|
||||
orElse: () => false,
|
||||
),
|
||||
onChanged: loading.value
|
||||
? null
|
||||
: unifiedPushAllowed.maybeWhen(
|
||||
data: (data) => data == null,
|
||||
orElse: () => false,
|
||||
)
|
||||
? pusherRegistered.maybeWhen(
|
||||
data: (_) => (value) async {
|
||||
try {
|
||||
loading.value = true;
|
||||
if (value) {
|
||||
if (await ref
|
||||
.watch(
|
||||
NotificationsController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.requestPermissions()) {
|
||||
await ref
|
||||
.watch(
|
||||
UnifiedPushController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.register();
|
||||
} else {
|
||||
// TODO: Handle not granted
|
||||
}
|
||||
} else {
|
||||
await ref
|
||||
.watch(
|
||||
UnifiedPushController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.deregister();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
orElse: () => null,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
icon: Icons.notification_add,
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Safety",
|
||||
icon: Icons.gpp_good,
|
||||
|
|
@ -166,6 +254,8 @@ class SettingsSectionsController
|
|||
onPressed: () async {
|
||||
Navigator.of(context)
|
||||
.popUntil((route) => route.isFirst);
|
||||
Navigator.of(context)
|
||||
.popUntil((route) => route.isFirst);
|
||||
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
|
||||
|
|
|
|||
164
lib/controllers/unified_push.dart
Normal file
164
lib/controllers/unified_push.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
import "package:nexus/controllers/notifications.dart";
|
||||
import "package:nexus/controllers/push_key.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/requests/register_pusher.dart";
|
||||
import "package:unifiedpush/unifiedpush.dart";
|
||||
import "package:unifiedpush_storage_shared_preferences/storage.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
class UnifiedPushController extends AsyncNotifier<bool> {
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final registered = await UnifiedPush.initialize(
|
||||
linuxOptions: .new(
|
||||
dbusName: "nexus.federated.nexus.UnifiedPush",
|
||||
storage: UnifiedPushStorageSharedPreferences(),
|
||||
background: isInBackground,
|
||||
),
|
||||
onNewEndpoint: (endpoint, instance) async {
|
||||
final pushKey = endpoint.pubKeySet!.pubKey;
|
||||
await ref
|
||||
.watch(PushKeyController.provider(instance).notifier)
|
||||
.set(pushKey);
|
||||
|
||||
await client.registerPusher(
|
||||
.new(
|
||||
appDisplayName: "Nexus",
|
||||
appId: "nexus.federated.nexus",
|
||||
data: PusherData.webPush(
|
||||
url: Uri.parse(endpoint.url),
|
||||
auth: endpoint.pubKeySet!.auth,
|
||||
),
|
||||
deviceDisplayName:
|
||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||
kind: .webPush,
|
||||
lang: "en",
|
||||
pushKey: pushKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMessage: (message, instance) async {
|
||||
if (message.decrypted == false) {
|
||||
throw Exception(
|
||||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||
);
|
||||
}
|
||||
final event = await client.handlePush(
|
||||
json.decode(String.fromCharCodes(message.content)),
|
||||
);
|
||||
|
||||
if (event == null ||
|
||||
event.unreadType?.shouldNotify != true ||
|
||||
(!isInBackground &&
|
||||
await windowManager.isFocused().onError((_, _) => false) &&
|
||||
ref.watch(KeyController.provider(KeyController.roomKey)) ==
|
||||
event.roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final room = ref.read(
|
||||
RoomsController.provider.select((rooms) => rooms[event.roomId]),
|
||||
);
|
||||
|
||||
final avatar = room?.metadata?.avatar;
|
||||
final icon = avatar == null
|
||||
? null
|
||||
: await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.downloadMedia(.new(mxc: avatar, isAvatar: true));
|
||||
|
||||
await ref
|
||||
.read(NotificationsController.provider.notifier)
|
||||
.send(
|
||||
id: event.eventId.hashCode & 0x7fffffff,
|
||||
title: room?.metadata?.name ?? "New Event",
|
||||
icon: icon,
|
||||
payload: event.eventId,
|
||||
body: switch (event.content) {
|
||||
MessageContent(:final body?) ||
|
||||
StickerContent(:final body) => body,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
|
||||
if (isInBackground) exit(0);
|
||||
},
|
||||
onRegistrationFailed: (error, instance) => throw error,
|
||||
onUnregistered: (instance) async {
|
||||
await ref
|
||||
.watch(PushKeyController.provider(instance).notifier)
|
||||
.set(null);
|
||||
ref.invalidateSelf();
|
||||
},
|
||||
);
|
||||
|
||||
if (registered) {
|
||||
// Needs to be registered every startup
|
||||
await register(true);
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
Future<void> register([bool alreadyRegistered = false]) async {
|
||||
final clientState = ref.watch(ClientStateController.provider);
|
||||
if (clientState?.deviceId == null) ref.invalidateSelf();
|
||||
|
||||
final capabilities = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.getCapabilities();
|
||||
|
||||
if (capabilities.webpush?.vapid == null) {
|
||||
throw UnsupportedError(
|
||||
"Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!alreadyRegistered &&
|
||||
!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
|
||||
throw Exception("No UnifiedPush distributors found");
|
||||
}
|
||||
|
||||
await UnifiedPush.register(
|
||||
instance: clientState!.deviceId!,
|
||||
vapid: capabilities.webpush?.vapid,
|
||||
);
|
||||
|
||||
if (!alreadyRegistered) ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> deregister() async {
|
||||
final clientState = ref.watch(ClientStateController.provider);
|
||||
final key = ref.watch(PushKeyController.provider(clientState!.deviceId!));
|
||||
|
||||
if (key != null) {
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
|
||||
} else {
|
||||
debugPrint(
|
||||
"No matching pushKey found. Skipping deregistration from homeserver.",
|
||||
);
|
||||
}
|
||||
|
||||
await UnifiedPush.unregister(clientState.deviceId!);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<UnifiedPushController, bool>(
|
||||
UnifiedPushController.new,
|
||||
);
|
||||
}
|
||||
33
lib/controllers/unified_push_allowed.dart
Normal file
33
lib/controllers/unified_push_allowed.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:unifiedpush/unifiedpush.dart";
|
||||
|
||||
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
|
||||
return "No valid distributors found. Try installing ${Platform.isLinux
|
||||
? "KUnifiedPush"
|
||||
: Platform.isAndroid
|
||||
? "Google Play Services or NTFY"
|
||||
: "NTFY"}.";
|
||||
}
|
||||
|
||||
final capabilities = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.getCapabilities();
|
||||
|
||||
if (capabilities.webpush?.vapid == null) {
|
||||
return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.autoDispose<UnifiedPushAllowedController, String?>(
|
||||
UnifiedPushAllowedController.new,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue