diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff533e..fac2ed8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,8 @@ "muks", "prefs", "unban", - "unredact" + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/README.md b/README.md index 0c18acf..ee55903 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ dart scripts/generate.dart > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > ``` +Build webcrypto: + +```sh +flutter pub run webcrypto:setup +``` + Build generated files, and watch for new changes: ```sh diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8a23bb6..9c0d96c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,16 +20,21 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } defaultConfig { applicationId = "nexus.federated.nexus" - minSdk = 29 + + minSdk = 29 targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode versionName = flutter.versionName + + multiDexEnabled = true } signingConfigs { @@ -63,6 +68,11 @@ android { } } +dependencies { + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 diff --git a/gomuks b/gomuks index 9444dd2..6c8b278 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 9444dd293664c300c6e710bb6cddcd9c6cab365d +Subproject commit 6c8b2781e02f27b64f37735cba6634784f117b88 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..c73d4ae 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,7 +7,8 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index b7d1eb4..60ab6ce 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -1,7 +1,9 @@ +import "dart:async"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; import "dart:math"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/foundation.dart"; @@ -14,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"; @@ -30,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"; @@ -153,18 +158,15 @@ class ClientController extends AsyncNotifier { throw Exception("GomuksStart returned error code $errorCode"); } - Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { + Future callGomuksMethod( + Map data, + FutureOr 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(), - bufferPointer.ref, - ), + () => callback(handle, bufferPointer.ref), ); calloc.free(bufferPointer); @@ -177,6 +179,24 @@ class ClientController extends AsyncNotifier { return json; } + Future handlePush(Map data) async { + final response = await callGomuksMethod( + data, + (handle, data) async => GomuksHandlePush(handle, data), + ); + + return response == null ? null : .fromJson(response); + } + + Future _sendCommand( + String command, [ + Map data = const {}, + ]) => callGomuksMethod( + data, + (handle, data) async => + GomuksSubmitCommand(handle, command.toNativeUtf8().cast(), data), + ); + Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); @@ -273,6 +293,15 @@ class ClientController extends AsyncNotifier { Future setAccountData(SetAccountDataRequest request) => _sendCommand("set_account_data", request.toJson()); + Future registerPusher(RegisterPusherRequest request) => + _sendCommand("register_homeserver_push", request.toJson()); + + Future deregisterPusher(DeregisterPusherRequest request) => + _sendCommand("register_homeserver_push", { + ...request.toJson(), + "kind": null, + }); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); @@ -310,6 +339,10 @@ class ClientController extends AsyncNotifier { Future getSpecVersions() async => .fromJson(await _sendCommand("get_versions")); + Future getCapabilities() async => Capabilities.fromJson( + (await _sendCommand("get_capabilities"))["capabilities"], + ); + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart new file mode 100644 index 0000000..bd80467 --- /dev/null +++ b/lib/controllers/notifications.dart @@ -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 { + @override + Future 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 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 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); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 042b42b..1e60263 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -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; diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..f9d8f8f --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,150 @@ +import "dart:convert"; +import "dart:io"; + +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/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 { + @override + Future 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) => 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: endpoint.pubKeySet!.pubKey, + ), + ), + 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) => ref.invalidateSelf(), + ); + + if (registered) { + // Needs to be registered every startup + await register(true); + } + + return registered; + } + + Future 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 deregister() async { + final clientState = ref.watch(ClientStateController.provider); + final key = await UnifiedPushStorageSharedPreferences().keys.get( + clientState!.deviceId!, + ); + + if (key == null) return; + + await ref + .watch(ClientController.provider.notifier) + .deregisterPusher( + .new(appId: "nexus.federated.nexus", pushKey: key.split("=").first), + ); + + await UnifiedPush.unregister(clientState.deviceId!); + ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider( + UnifiedPushController.new, + ); +} diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart new file mode 100644 index 0000000..4f925cf --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -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 { + @override + Future build() async { + if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + return "No valid distributors found. Try installing ${Platform.isLinux + ? "KUnifiedPush" + : Platform.isAndroid + ? "FCM 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.new, + ); +} diff --git a/lib/main.dart b/lib/main.dart index a87d7f5..aaed5a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,10 @@ import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/font_licenses.dart"; @@ -22,6 +24,7 @@ import "package:window_manager/window_manager.dart"; import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -45,6 +48,7 @@ void showError(Object error, [StackTrace? stackTrace]) { error.toString().contains("Invalid source") || error.toString().contains("UTF-16") || error.toString().contains("HTTP request failed") || + error.toString().contains("'_nextFrame != null': is not true.") || error.toString().contains("Invalid image data")) { return; } @@ -62,7 +66,7 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); @@ -77,25 +81,30 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } + isInBackground = args.contains("--unifiedpush-bg"); + LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - retry: (_, _) => null, - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), - ], - child: const App(), - ), - ); + if (!isInBackground) { + runApp( + ProviderScope( + retry: (_, _) => null, + observers: [ + // Change false to true if you want debug information on provider reloads + // ignore: dead_code + if (false && kDebugMode) Logger(), + ], + child: App(isInBackground), + ), + ); + } } -class const App({super.key}) extends StatelessWidget { +class const App(final bool isInBackground, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( @@ -138,6 +147,8 @@ class const App({super.key}) extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, + NotificationsController.provider, + UnifiedPushController.provider, ]), ), ) diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..6830170 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "capabilities.freezed.dart"; +part "capabilities.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Capabilities({ + @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, +}) with _$Capabilities { + Map toJson() => _$CapabilitiesToJson(this); + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const WebPush({required final bool enabled, final String? vapid}) + with _$WebPush { + Map toJson() => _$WebPushToJson(this); + + factory WebPush.fromJson(Map json) => + _$WebPushFromJson(json); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index ff433ab..262203d 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -10,6 +10,7 @@ class const ClientState({ required final bool isLoggedIn, required final bool isVerified, required final String? userId, + required final String? deviceId, required final String? homeserverUrl, }) with _$ClientState { Map toJson() => _$ClientStateToJson(this); diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 3fade23..44fb9bb 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: .snake) enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart index d852164..f03ca99 100644 --- a/lib/models/membership_action.dart +++ b/lib/models/membership_action.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index ba7a241..7eb5f72 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/requests/deregister_pusher.dart b/lib/models/requests/deregister_pusher.dart new file mode 100644 index 0000000..1416add --- /dev/null +++ b/lib/models/requests/deregister_pusher.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "deregister_pusher.freezed.dart"; +part "deregister_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DeregisterPusherRequest({ + required final String appId, + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$DeregisterPusherRequest { + Map toJson() => _$DeregisterPusherRequestToJson(this); +} diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart new file mode 100644 index 0000000..dd34e62 --- /dev/null +++ b/lib/models/requests/register_pusher.dart @@ -0,0 +1,56 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_pusher.freezed.dart"; +part "register_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RegisterPusherRequest({ + required final String appDisplayName, + required final String appId, + final bool append = false, + required final PusherData data, + required final String deviceDisplayName, + required final PusherKind kind, + required final String lang, + + /// TODO: What does this do? + final String? profileTag, + + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$RegisterPusherRequest { + Map toJson() => _$RegisterPusherRequestToJson(this); +} + +@freezed +sealed class PusherData with _$PusherData { + const factory PusherData.http({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + }) = HttpPusherData; + + const factory PusherData.webPush({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + + /// `data.auth`: RFC8291 authentication secret. + required String auth, + }) = WebPushPusherData; + + factory PusherData.fromJson(Map json) => + _$PusherDataFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum PushFormat { + @JsonValue(null) + all, + eventIdOnly, +} + +enum PusherKind { + http, + email, + @JsonValue("org.matrix.msc4174.webpush") + webPush, +} diff --git a/lib/pages/notifications.dart b/lib/pages/notifications.dart new file mode 100644 index 0000000..ceca514 --- /dev/null +++ b/lib/pages/notifications.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/widgets/appbar.dart"; + +class const NotificationsPage({final String? eventId, super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: Appbar(title: Text("Notifications")), + body: Placeholder(), + ); + } +} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index a6058ea..33727ac 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -39,7 +39,7 @@ final class const Appbar({ : (_) => windowManager.startDragging(), ), child: AppBar( - leading: InkWell(onTap: onTap, child: leading), + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, actionsPadding: const .symmetric(horizontal: 8), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index dd3c78e..8bc8641 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -45,7 +45,6 @@ pkgs.mkShell { GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; } // lib.optionalAttrs pkgs.stdenv.isLinux { - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; }; } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 62e5450..739e6df 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,5 +1,6 @@ #include "my_application.h" +#include #include #ifdef GDK_WINDOWING_X11 #include @@ -72,6 +73,7 @@ static void my_application_activate(GApplication* application) { gdk_rgba_parse(&background_color, "#000000"); fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); + if (std::getenv("FLUTTER_HEADLESS")) gtk_widget_hide(GTK_WIDGET(window)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); // Show the window when Flutter renders. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..8b6bbb6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import app_links import dynamic_color import file_selector_macos +import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -15,12 +16,14 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) @@ -28,5 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index d45c4d4..5f85f85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" args: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -277,10 +277,10 @@ packages: dependency: transitive description: name: cupertino_ui - sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d" + sha256: "2137c0d41f3b62cc4d3d2495e6c354bd6b6133cd21c6bb155736cc31dbd49a11" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" dart_style: dependency: transitive description: @@ -511,6 +511,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: "direct main" + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -891,10 +931,10 @@ packages: dependency: "direct main" description: name: material_ui - sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b" + sha256: "7ba1ca315d50a004791dbab290417c52a61466d56db2822fe3c5c2994903110c" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" measure_size: dependency: "direct main" description: @@ -1501,6 +1541,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -1509,6 +1557,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc + url: "https://pub.dev" + source: hosted + version: "3.5.0" + unifiedpush_linux: + dependency: transitive + description: + name: unifiedpush_linux + sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + unifiedpush_storage_interface: + dependency: transitive + description: + name: unifiedpush_storage_interface + sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_storage_shared_preferences: + dependency: "direct main" + description: + name: unifiedpush_storage_shared_preferences + sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4 + url: "https://pub.dev" + source: hosted + version: "1.0.0" universal_platform: dependency: transitive description: @@ -1685,6 +1781,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webcrypto: + dependency: "direct dev" + description: + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + url: "https://pub.dev" + source: hosted + version: "0.6.0" webkit_inspection_protocol: dependency: transitive description: @@ -1693,6 +1797,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9b2e01..de72e0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: dynamic_polls: 0.0.8 flutter_hooks: 0.21.3+1 ffi: 2.2.0 - hooks: ^2.2.0 + hooks: 2.2.0 code_assets: 1.2.1 ffigen: 21.0.0 timeago: 3.7.1 @@ -78,7 +78,11 @@ dependencies: package_info_plus: 10.2.1 app_links: 7.2.1 file_selector: 1.1.0 - material_ui: 1.0.1 + material_ui: 1.1.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: 22.3.0 + flutter_local_notifications_linux: 8.0.1 dev_dependencies: build_runner: 2.16.0 @@ -87,6 +91,8 @@ dev_dependencies: riverpod_lint: 3.1.8 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 + # Transitive dep but needed for commands + webcrypto: any flutter_launcher_icons: image_path: assets/icon.png diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..c74b2f5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,10 +10,12 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)