From 0ad85f24d5c3bbfa905ab57b5cafd8e204b6caec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:05:26 -0400 Subject: [PATCH 01/21] Add support for downloading media Fixes #85 --- lib/helpers/extensions/save_attachment.dart | 34 ++++++++ lib/helpers/file_dialog_helper.dart | 93 +++++++++++++++++++++ lib/widgets/composer/composer.dart | 22 +++-- lib/widgets/expandable_image.dart | 26 +++++- lib/widgets/file_card.dart | 18 +++- lib/widgets/html/html.dart | 1 + lib/widgets/message_image.dart | 2 + lib/widgets/players/audio.dart | 22 ++++- lib/widgets/players/video.dart | 20 +++++ lib/widgets/renderers/message.dart | 55 ++++++++++-- lib/widgets/room_summary_sheet.dart | 1 + lib/widgets/space_hierarchy_sheet.dart | 2 + lib/widgets/user_bottom_sheet.dart | 1 + pubspec.lock | 10 ++- pubspec.yaml | 2 + 15 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 lib/helpers/extensions/save_attachment.dart create mode 100644 lib/helpers/file_dialog_helper.dart diff --git a/lib/helpers/extensions/save_attachment.dart b/lib/helpers/extensions/save_attachment.dart new file mode 100644 index 0000000..1fb048f --- /dev/null +++ b/lib/helpers/extensions/save_attachment.dart @@ -0,0 +1,34 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; + +extension SaveAttachment on BuildContext { + /// Downloads the media at [mxc], displaying a snackbar whilst it's donwnloading, + /// then lets the user pick where to save it, suggesting [filename]. + Future saveAttachment( + WidgetRef ref, + Uri mxc, { + required bool encrypted, + String? filename, + }) async { + final file = await withProgressSnackbar( + "Downloading file...", + () => ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: mxc, encrypted: encrypted)), + ); + if (file == null || !mounted) return; + + final messenger = ScaffoldMessenger.of(this); + try { + await ref + .read(FileDialogHelper.provider) + .saveFile(file, filename ?? mxc.pathSegments.last); + } catch (error) { + messenger.showErrorSnackBar(error.toString()); + } + } +} diff --git a/lib/helpers/file_dialog_helper.dart b/lib/helpers/file_dialog_helper.dart new file mode 100644 index 0000000..de93d12 --- /dev/null +++ b/lib/helpers/file_dialog_helper.dart @@ -0,0 +1,93 @@ +import "dart:io"; + +import "package:file_selector/file_selector.dart"; +import "package:flutter_file_dialog/flutter_file_dialog.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:mime/mime.dart"; +import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class FileDialogHelper(final Ref ref) { + Future saveFile(File file, String filename) async { + final mimeType = lookupMimeType( + file.path, + headerBytes: await file + .openRead(0, defaultMagicNumbersMaxLength) + .expand((bytes) => bytes) + .toList(), + ); + + final filenameWithExtension = mimeType == null + ? filename + : "$filename.${extensionFromMime(mimeType)}"; + + if (Platform.isAndroid || Platform.isIOS) { + return await FlutterFileDialog.saveFile( + params: .new( + sourceFilePath: file.path, + fileName: filenameWithExtension, + ), + ) != + null; + } + + final destination = Platform.isLinux + ? await getLinuxSaveLocation(filenameWithExtension) + : (await getSaveLocation(suggestedName: filenameWithExtension))?.path; + if (destination == null) return false; + + await file.copy(destination); + return true; + } + + Future getLinuxSaveLocation(String filename) async { + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .saveFile(title: "Save file", currentName: filename) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => Uri.parse(uri).toFilePath(), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + /// Lets the user pick a file, or only images if [images] is true. Returns + /// null if the user cancelled. + Future pickFile({bool images = false}) async { + if (!Platform.isLinux) { + return images + ? ref.read(ImagePickerController.provider).pickImage(source: .gallery) + : openFile(); + } + + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .openFile( + title: images ? "Pick image" : "Pick file", + filters: [ + if (images) + .new("Images", [XdgFileChooserMimeTypePattern("image/*")]), + ], + ) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => XFile(Uri.parse(uri).toFilePath()), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + static final provider = Provider(FileDialogHelper.new); +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 82f0087..9b596c6 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -1,7 +1,6 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:file_selector/file_selector.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -13,6 +12,7 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/models/content/message.dart"; @@ -245,14 +245,24 @@ class const Composer( leading: Icon(icon), ), onTap: () => attach( - ref - .read(ImagePickerController.provider) - .pickImage(source: source), + source == .gallery + ? ref + .read(FileDialogHelper.provider) + .pickFile(images: true) + : ref + .read( + ImagePickerController + .provider, + ) + .pickImage(source: source), ).onError(showError), ), PopupMenuItem( - onTap: () => - attach(openFile()).onError(showError), + onTap: () => attach( + ref + .read(FileDialogHelper.provider) + .pickFile(), + ).onError(showError), child: ListTile( title: Text("Files"), leading: Icon(Icons.attachment), diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index 0639417..f10221e 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,6 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/mxc_image.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/widgets/error_dialog.dart"; @@ -8,6 +9,9 @@ import "package:nexus/widgets/error_dialog.dart"; final class const ExpandableImage( final DownloadMediaRequest? request, { required final Widget child, + + /// If set, a download button is shown, suggesting this filename + final String? downloadFilename, super.key, }) extends ConsumerWidget { @override @@ -38,9 +42,25 @@ final class const ExpandableImage( alignment: .topRight, child: Padding( padding: .all(32), - child: M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request!.mxc, + encrypted: request!.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], ), ), ), diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart index 4c0fdde..4c3ce43 100644 --- a/lib/widgets/file_card.dart +++ b/lib/widgets/file_card.dart @@ -1,4 +1,6 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/helpers/extensions/size_to_string.dart"; import "package:nexus/models/info/file.dart"; @@ -6,10 +8,11 @@ final class const FileCard( final Uri uri, final FileInfo? info, { final String? filename, + final bool encrypted = false, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) => SizedBox( + Widget build(BuildContext context, WidgetRef ref) => SizedBox( width: 320, child: Card( color: Theme.of(context).colorScheme.surfaceContainer, @@ -17,8 +20,15 @@ final class const FileCard( leading: Icon(Icons.file_copy), title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), - // TODO: Downloading files - trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + trailing: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + icon: Icon(Icons.download), + ), ), ), ); diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 325e449..5ea584f 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -79,6 +79,7 @@ class const Html( alignment: PlaceholderAlignment.middle, child: ExpandableImage( .new(mxc: src), + downloadFilename: src.pathSegments.last, child: Image( image: MxcImage(ref, .new(mxc: src)), errorBuilder: (_, error, _) => Text( diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index b04c209..42c08d6 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -11,6 +11,7 @@ final class const MessageImage( final Uri url, { final i.ImageInfo? info, required final bool encrypted, + final String? filename, super.key, }) extends ConsumerWidget { @override @@ -18,6 +19,7 @@ final class const MessageImage( final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); return ExpandableImage( request, + downloadFilename: filename, child: ClipRRect( borderRadius: .all(.circular(8)), child: AspectRatio( diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index cd69126..598687c 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -5,6 +5,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/audio.dart"; import "package:nexus/widgets/players/player_controls.dart"; @@ -12,6 +13,7 @@ class const AudioPlayer( final Uri uri, final AudioInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -38,8 +40,24 @@ class const AudioPlayer( child: Card( color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( - padding: .only(left: 8, right: 16), - child: PlayerControls(player, loading: !loaded.value), + padding: .symmetric(horizontal: 8), + child: PlayerControls( + player, + loading: !loaded.value, + actions: .new([ + SizedBox(width: 4), + IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ]), + ), ), ), ); diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 385602d..557e7c1 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -3,6 +3,7 @@ import "dart:async"; import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; @@ -13,6 +14,7 @@ class const VideoPlayer( final Uri uri, final VideoInfo? info, { final bool encrypted = false, + final String? filename, super.key, }) extends HookConsumerWidget { @override @@ -61,6 +63,24 @@ class const VideoPlayer( onTap: loaded.value ? player.playOrPause : null, ), ), + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black38, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ), Positioned( left: 0, right: 0, diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 3404bc5..598e9f9 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -110,14 +110,53 @@ class const MessageRenderer( ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { - VideoMessageContent(:final info, :final file) => - VideoPlayer(url, info, encrypted: file != null), - AudioMessageContent(:final info, :final file) => - AudioPlayer(url, info, encrypted: file != null), - FileMessageContent(:final info, :final filename) => - FileCard(url, info, filename: filename), - ImageMessageContent(:final info, :final file) => - MessageImage(url, info: info, encrypted: file != null), + VideoMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + VideoPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + AudioMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + AudioPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + FileMessageContent( + :final info, + :final filename, + :final file, + ) => + FileCard( + url, + info, + filename: filename, + encrypted: file != null, + ), + ImageMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + MessageImage( + url, + info: info, + encrypted: file != null, + filename: filename ?? body, + ), _ => SizedBox.shrink(), }, ), diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index bfc14c7..60c5baa 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -30,6 +30,7 @@ class const RoomSummarySheet( if (summary.avatarUrl != null) ExpandableImage( summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + downloadFilename: name, child: AvatarOrHash( summary.avatarUrl, "", diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 7f8b588..85ae46c 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -236,6 +236,8 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) if (space?.avatarUrl case final avatarUrl?) ExpandableImage( .new(mxc: avatarUrl), + downloadFilename: + space?.name ?? space?.canonicalAlias ?? spaceId, child: AvatarOrHash(avatarUrl, "", dimension: 32), ), Expanded( diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 0232b08..b6da63d 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -103,6 +103,7 @@ final class const UserBottomSheet( ExpandableImage( member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, child: AvatarOrHash( member.avatarUrl, member.displayName ?? userId.localpart, diff --git a/pubspec.lock b/pubspec.lock index 6b755da..51443ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -455,6 +455,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_file_dialog: + dependency: "direct main" + description: + name: flutter_file_dialog + sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5" + url: "https://pub.dev" + source: hosted + version: "3.3.3" flutter_hooks: dependency: "direct main" description: @@ -985,7 +993,7 @@ packages: source: hosted version: "1.19.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 diff --git a/pubspec.yaml b/pubspec.yaml index 1c6fb4a..431a328 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,6 +107,8 @@ dependencies: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart ref: quad/nexus pasteboard: 0.5.0 + flutter_file_dialog: 3.3.3 + mime: 2.1.0 dev_dependencies: build_runner: 2.16.2 From f19a51939c9b63be0d313ce1565b2806b54c3302 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:06:22 -0400 Subject: [PATCH 02/21] small cleanups --- lib/controllers/settings_sections.dart | 2 -- lib/models/requests/register_pusher.dart | 2 -- lib/widgets/html/mention_chip.dart | 1 - 3 files changed, 5 deletions(-) diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index bf84252..68f0891 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -226,8 +226,6 @@ class SettingsSectionsController .notifier, ) .register(value); - } else { - // TODO: Handle not granted } } else { await ref diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart index dd34e62..f38ecc0 100644 --- a/lib/models/requests/register_pusher.dart +++ b/lib/models/requests/register_pusher.dart @@ -13,8 +13,6 @@ class const RegisterPusherRequest({ 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, diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index c6a6e5b..94aec91 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -43,7 +43,6 @@ class const MentionChip(final String content, final String? roomId, {super.key}) if (data case MembershipContent membership) { context.showUserPopover(membership, mention, roomId: roomId); } else if (data case RoomSummary summary) { - // TODO: This is an extra call that isn't truly needed, get rid of it final eventRowId = linkInfo?.eventId == null ? null : await ref From 81851bdc421cd6ab41dbd08f1694f63a4e708c21 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:13:25 -0400 Subject: [PATCH 03/21] fix no room selected after leave / init --- lib/controllers/room_chat.dart | 1 + lib/controllers/selected_room.dart | 31 +++++++++++++++++++++++++++++ lib/controllers/selected_space.dart | 25 +++++++++++++++++++++++ lib/controllers/unified_push.dart | 7 ++----- lib/widgets/pages/chat.dart | 6 ++---- lib/widgets/room_list.dart | 22 ++++---------------- 6 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 lib/controllers/selected_room.dart create mode 100644 lib/controllers/selected_space.dart diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 0d0688e..c4a8dda 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -25,6 +25,7 @@ class RoomChatController(final ChatParams info) @override Future build() async { final client = ref.read(ClientController.provider.notifier); + final room = ref.read( RoomsController.provider.select((rooms) => rooms[info.roomId]), ); diff --git a/lib/controllers/selected_room.dart b/lib/controllers/selected_room.dart new file mode 100644 index 0000000..f59232c --- /dev/null +++ b/lib/controllers/selected_room.dart @@ -0,0 +1,31 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_space.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; + +class SelectedRoomController extends Notifier { + @override + String? build() { + final roomId = ref.watch( + KeyController.provider(KeyController.roomKey) + .select((async) => async.value), + ); + + final roomIds = ref.watch( + SelectedSpaceController.provider.select( + (space) => space?.allChildRooms + .map((room) => room.metadata?.id) + .nonNulls + .toIList(), + ), + ); + if (roomIds == null) return roomId; + + return roomIds.contains(roomId) ? roomId : roomIds.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedRoomController.new, + ); +} diff --git a/lib/controllers/selected_space.dart b/lib/controllers/selected_space.dart new file mode 100644 index 0000000..7a54060 --- /dev/null +++ b/lib/controllers/selected_space.dart @@ -0,0 +1,25 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/space.dart"; + +class SelectedSpaceController extends Notifier { + @override + Space? build() { + final spaces = ref.watch(SpacesController.provider).value; + if (spaces == null) return null; + + final spaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.value), + ); + + return spaces.firstWhereOrNull((space) => space.id == spaceId) ?? + spaces.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedSpaceController.new, + ); +} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 638e330..8dcb295 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -5,7 +5,7 @@ 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/selected_room.dart"; import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/push_key.dart"; import "package:nexus/main.dart"; @@ -90,10 +90,7 @@ class UnifiedPushController extends AsyncNotifier { if (event.unreadType?.has(.notify) != true || (!isInBackground && await windowManager.isFocused().onError((_, _) => true) && - await ref.read( - KeyController.provider(KeyController.roomKey).future, - ) == - event.roomId)) { + ref.read(SelectedRoomController.provider) == event.roomId)) { if (isInBackground) exit(0); return; } diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 8502d02..e60ead7 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -2,7 +2,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/init_complete.dart"; -import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; import "package:nexus/controllers/notification_request.dart"; import "package:nexus/helpers/extensions/is_desktop.dart"; import "package:nexus/widgets/appbar.dart"; @@ -37,9 +37,7 @@ class const ChatPage({super.key}) extends HookConsumerWidget { builder: (context, constraints) { final isDesktop = context.isDesktop; final showMembersByDefault = constraints.maxWidth > 1000; - final roomId = ref - .watch(KeyController.provider(KeyController.roomKey)) - .requireValue; + final roomId = ref.watch(SelectedRoomController.provider); return SafeArea( child: Scaffold( diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 9562e0f..f8d366e 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -22,9 +22,10 @@ import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_menu_button.dart"; import "package:nexus/widgets/space_hierarchy_sheet.dart"; import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/selected_space.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/spaces.dart"; -import "package:collection/collection.dart"; // Needed for navigation_rail_m3e (#65). import "package:flutter/material.dart" as old_mat; @@ -217,23 +218,8 @@ class const SpacesRail({super.key}) extends ConsumerWidget { class const RoomsPane({super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey) - .select((async) => async.requireValue), - ); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey) - .select((async) => async.requireValue), - ); - final selectedSpace = ref.watch( - SpacesController.provider.select((async) { - final spaces = async.requireValue; - return spaces.firstWhereOrNull( - (space) => space.id == selectedSpaceId, - ) ?? - spaces.first; - }), - ); + final selectedRoomId = ref.watch(SelectedRoomController.provider); + final selectedSpace = ref.watch(SelectedSpaceController.provider)!; final isDm = selectedSpace.id == "dms"; final spaceMetadata = selectedSpace.room?.metadata; From a3a3781252bbe01c5fd432592caa985c748795a7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:17:19 -0400 Subject: [PATCH 04/21] open sidebar on homepage pop --- lib/widgets/pages/chat.dart | 66 ++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index e60ead7..9e18da7 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -39,38 +39,44 @@ class const ChatPage({super.key}) extends HookConsumerWidget { final showMembersByDefault = constraints.maxWidth > 1000; final roomId = ref.watch(SelectedRoomController.provider); - return SafeArea( - child: Scaffold( - key: scaffoldKey, - appBar: initComplete ? null : Appbar(), - body: initComplete - ? Row( - children: [ - if (isDesktop) Sidebar(), - Expanded( - child: RoomChat( - key: ValueKey(roomId), - roomId: roomId, - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ) - : Column( - children: [ - const SyncStatusBanner(), - Expanded( - child: Center( - child: Column( - mainAxisSize: .min, - children: [Loading(), Text("Syncing...")], + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) scaffoldKey.currentState?.openDrawer(); + }, + child: SafeArea( + child: Scaffold( + key: scaffoldKey, + appBar: initComplete ? null : Appbar(), + body: initComplete + ? Row( + children: [ + if (isDesktop) Sidebar(), + Expanded( + child: RoomChat( + key: ValueKey(roomId), + roomId: roomId, + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), ), - ), - ], - ), - drawer: isDesktop || !initComplete ? null : Sidebar(), + ], + ) + : Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Center( + child: Column( + mainAxisSize: .min, + children: [Loading(), Text("Syncing...")], + ), + ), + ), + ], + ), + drawer: isDesktop || !initComplete ? null : Sidebar(), + ), ), ); }, From 1380233f2ec907314d201f6ad176d61353021c53 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 17:20:36 -0400 Subject: [PATCH 05/21] release v1.0.0-alpha3 --- README.md | 2 +- linux/nix/pkg/default.nix | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 97f5ec8..66605b3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ nexus = { This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: ```nix -url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha2"; +url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha3"; ``` Then import the module and enable it in your NixOS configuration: diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 7aabf9b..d7eb059 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -12,7 +12,7 @@ flutter.buildFlutterApplication { pname = "nexus"; - version = "1.0.0-alpha2"; + version = "1.0.0-alpha3"; inherit src; preBuild = '' diff --git a/pubspec.yaml b/pubspec.yaml index 431a328..8802215 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "A simple and user-friendly Matrix client" -version: 1.0.0-alpha2 +version: 1.0.0-alpha3 publish_to: none flutter: From 2f17f5b6c69c1f3093c7aebd1c2e37fdbebc999b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 18:08:42 -0400 Subject: [PATCH 06/21] add swipe to reply Fixes #90 --- lib/controllers/settings_sections.dart | 15 ++++++ lib/models/settings.dart | 1 + lib/widgets/renderers/event.dart | 31 ++++++++----- lib/widgets/room_chat/chat_timeline.dart | 2 + lib/widgets/room_chat/room_chat.dart | 26 +++++++++-- lib/widgets/swipe_to_reply.dart | 58 ++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/swipe_to_reply.dart diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 68f0891..c21d7ef 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -117,6 +117,21 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Swipe To Reply", + description: "Swipe a message to the right to reply to it.", + icon: Icons.swipe_right, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.swipeToReply, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(swipeToReply: value)) + .onError(showError), + ), + ), if (!Platform.isAndroid && !Platform.isIOS) .new( title: "Show Minimize Button", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 407aa19..f9198ed 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -13,6 +13,7 @@ class const Settings({ final bool linuxMobileMode = false, final bool showAllInHome = false, final bool showMinimizeButton = false, + final bool swipeToReply = false, final RoomOrder roomOrder = .unreadFirst, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 14e5c94..3ec3d4a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,7 @@ import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/timestamp.dart"; class const EventRenderer( @@ -39,6 +40,9 @@ class const EventRenderer( final int? maxLines, final JumpToEvent? jumpToEvent, final IList Function(Event event)? getEventOptions, + + /// If set, events can be swiped, calling this + final VoidCallback? onSwipe, super.key, }) extends HookConsumerWidget { @override @@ -239,18 +243,21 @@ class const EventRenderer( child: GestureDetector( onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8) - .copyWith(top: isGrouped ? 0 : 8), - child: Wrap( - verticalDirection: .up, - spacing: 6, - crossAxisAlignment: .center, - children: [ - child, - if (child is! MessageRenderer) - Timestamp(event.timestamp), - ], + child: SwipeToReply( + onSwipe: onSwipe, + Padding( + padding: EdgeInsets.symmetric(horizontal: 8) + .copyWith(top: isGrouped ? 0 : 8), + child: Wrap( + verticalDirection: .up, + spacing: 6, + crossAxisAlignment: .center, + children: [ + child, + if (child is! MessageRenderer) + Timestamp(event.timestamp), + ], + ), ), ), ), diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 254c782..8a4c071 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -13,6 +13,7 @@ class const ChatTimeline({ required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, + final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { Widget eventRow( @@ -26,6 +27,7 @@ class const ChatTimeline({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), ), key: key, isHighlighted: highlightedEvent == eventRowId, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index f45a6f2..155e90e 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -7,6 +7,8 @@ import "package:measure_size/measure_size.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; @@ -171,19 +173,30 @@ final class const RoomChat({ return null; }, [jumpRowId]); + void onRelation(int rowId, RelationType type) { + relatedEvent.value = rowId; + relationType.value = type; + composerNode.requestFocus(); + } + IList getEventOptions(Event event) => event.buildEventOptions( context: context, ref: ref, roomId: roomId, userId: userId, - onRelation: (rowId, type) { - relatedEvent.value = rowId; - relationType.value = type; - composerNode.requestFocus(); - }, + onRelation: onRelation, ); + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final canReply = ref.watch( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); + return Scaffold( endDrawer: PinnedEventsDrawer( roomId, @@ -224,6 +237,9 @@ final class const RoomChat({ roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, highlightedEvent: highlightedEvent.value, composerHeight: composerSize.value, ), diff --git a/lib/widgets/swipe_to_reply.dart b/lib/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..c02b310 --- /dev/null +++ b/lib/widgets/swipe_to_reply.dart @@ -0,0 +1,58 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +class const SwipeToReply( + final Widget child, { + required final VoidCallback? onSwipe, + super.key, +}) extends HookWidget { + static const maxDrag = 64.0; + static const threshold = 0.75; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: Durations.short4); + useListenable(controller); + + if (onSwipe == null) return child; + + final progress = controller.value; + + return GestureDetector( + onHorizontalDragUpdate: (details) { + final previous = controller.value; + controller.value = previous + details.primaryDelta! / maxDrag; + + if (previous < threshold && controller.value >= threshold) { + HapticFeedback.lightImpact(); + } + }, + onHorizontalDragEnd: (_) { + if (controller.value >= threshold) onSwipe!(); + controller.animateBack(0, curve: Curves.easeOut); + }, + onHorizontalDragCancel: () => + controller.animateBack(0, curve: Curves.easeOut), + child: Stack( + alignment: .centerLeft, + children: [ + Positioned( + left: 16, + child: Opacity( + opacity: (progress / threshold).clamp(0, 1), + child: Transform.scale( + scale: progress >= threshold ? 1 : 0.75, + child: const Icon(Icons.reply), + ), + ), + ), + Transform.translate( + offset: .new(progress * maxDrag, 0), + child: child, + ), + ], + ), + ); + } +} From 6d424770418b1a889ffef4470e3554fb73636fb6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 20:35:42 -0400 Subject: [PATCH 07/21] add support for avif images Fixes #138 --- android/build.gradle.kts | 17 +++ lib/helpers/mxc_image.dart | 42 +++++- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + linux/nix/devshell.nix | 17 ++- macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 136 ++++++++++++++++++ pubspec.yaml | 2 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 219 insertions(+), 8 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 441eb71..f82adc9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -16,6 +16,23 @@ subprojects { project.layout.buildDirectory.value(newSubprojectBuildDir) } +subprojects { + if (name == "flutter_avif_android") { + tasks.withType().configureEach { + exclude("**/FlutterAvifPlugin.kt") + } + afterEvaluate { + extensions.configure { + compileSdk = 36 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + } + } + } +} + subprojects { project.evaluationDependsOn(":app") } diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart index 83a345a..83bcf14 100644 --- a/lib/helpers/mxc_image.dart +++ b/lib/helpers/mxc_image.dart @@ -1,6 +1,8 @@ -import "dart:ui"; +import "dart:io"; +import "dart:ui" as ui; import "package:flutter/widgets.dart"; +import "package:flutter_avif/flutter_avif.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/models/requests/download_media.dart"; @@ -15,13 +17,23 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) => MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0); - Future _loadAsync(ImageDecoderCallback decode) async { + Future _loadAsync(ImageDecoderCallback decode) async { final file = await ref .read(ClientController.provider.notifier) .downloadMedia(request); - final buffer = await ImmutableBuffer.fromFilePath(file.path); + final bytes = await File(file.path).readAsBytes(); - return decode(buffer); + // Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves + final avifType = isAvifFile(bytes.sublist(0, 16.clamp(0, bytes.length))); + if (avifType != .unknown) { + final codec = avifType == .avif + ? SingleFrameAvifCodec(bytes: bytes) + : MultiFrameAvifCodec(key: hashCode, avifBytes: bytes); + await codec.ready(); + return AvifCodecAdapter(codec); + } + + return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); } @override @@ -31,3 +43,25 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) @override int get hashCode => request.hashCode; } + +class AvifCodecAdapter(final AvifCodec codec) implements ui.Codec { + @override + int get frameCount => codec.frameCount; + + @override + int get repetitionCount => frameCount > 1 ? -1 : 0; + + @override + Future getNextFrame() async { + final frame = await codec.getNextFrame(); + return AvifFrameInfo(frame.duration, frame.image); + } + + @override + void dispose() => codec.dispose(); +} + +class AvifFrameInfo( + @override final Duration duration, + @override final ui.Image image, +) implements ui.FrameInfo; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b2ee366..d95018b 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); + flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eea220b..7d212bd 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links_linux dynamic_color file_selector_linux + flutter_avif_linux media_kit_libs_linux media_kit_video pasteboard diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index 6b2b94d..bfa5408 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -9,6 +9,7 @@ let ]; cmakeVersions = [ "3.22.1" ]; platformVersions = [ + "31" "34" "36" ]; @@ -47,7 +48,17 @@ pkgs.mkShell { GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; } - // lib.optionalAttrs pkgs.stdenv.isLinux { - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - }; + // lib.optionalAttrs pkgs.stdenv.isLinux ( + let + arch = if pkgs.stdenv.hostPlatform.isAarch64 then "arm64" else "x64"; + in + { + LD_LIBRARY_PATH = lib.concatStringsSep ":" [ + "./build/native_assets/linux" + "./build/linux/${arch}/debug/bundle/lib" + "./build/linux/${arch}/release/bundle/lib" + (lib.makeLibraryPath [ pkgs.zlib ]) + ]; + } + ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2a15f97..c801836 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_avif_macos import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video @@ -15,6 +16,7 @@ import package_info_plus import pasteboard import screen_retriever_macos import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos import wakelock_plus import webcrypto @@ -24,6 +26,7 @@ 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")) + FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) @@ -31,6 +34,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 51443ce..bf9c326 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -313,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + exif: + dependency: transitive + description: + name: exif + sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 + url: "https://pub.dev" + source: hosted + version: "3.3.0" fab_m3e: dependency: "direct overridden" description: @@ -447,6 +455,70 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_avif: + dependency: "direct main" + description: + name: flutter_avif + sha256: "6035f073189c1ae134affa73338c2eca79bf412e9abdb8f8628fdefcccff4444" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_android: + dependency: transitive + description: + name: flutter_avif_android + sha256: "1da135ea0d74225fae3154f954f9ba4cf7aa5e56b30d9851fcfc0ddecab9d8c5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_ios: + dependency: transitive + description: + name: flutter_avif_ios + sha256: "3daaa599d8fe0193d3ade6cafa1fd4f166d4063b8d499c7c311ce1d170b2759f" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_linux: + dependency: transitive + description: + name: flutter_avif_linux + sha256: dd6179edca12c720761b3acc678d7f15208eb8647adbac5a6a90f52d4ba56e2d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_macos: + dependency: transitive + description: + name: flutter_avif_macos + sha256: "8e39f365dfc5713d4527f07a92399184857ea30eced85f82171bfd0a45e2c73b" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_platform_interface: + dependency: transitive + description: + name: flutter_avif_platform_interface + sha256: f5c110bdb7de2d4ab4902bfe2ec331fe24bc19b6d54f00ac4fa165effc0edcda + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_web: + dependency: transitive + description: + name: flutter_avif_web + sha256: db2ff395b08cbfe9116e48ac65c428ba21a9159972ea019f8d7be580ff6cf6e8 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_windows: + dependency: transitive + description: + name: flutter_avif_windows + sha256: e0c7722df305a6621f4ecdd83098025c705bcf0e8ce9519e03c6f515abcf351d + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_blurhash: dependency: "direct main" description: @@ -455,6 +527,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: f94fc6f5480977e3eb7b622ed488f671ba96c971bd0e274e6bd0f0ba9b044816 + url: "https://pub.dev" + source: hosted + version: "3.4.5" flutter_file_dialog: dependency: "direct main" description: @@ -1185,6 +1265,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" pub_semver: dependency: transitive description: @@ -1398,6 +1486,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "273218a1678360a3127fcf15e543381d9b813d413d783bc6e9a53eed4d2eb8fe" + url: "https://pub.dev" + source: hosted + version: "2.4.4+2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: e4f659c14bdd14464127fcd234a7a96f61fe0f539b8568e5a38f99f5a568a835 + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 + url: "https://pub.dev" + source: hosted + version: "2.5.14" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "8de68fc306040f992aaf378750c16f95e4298c4bb150dd45091d75e42b1f08ee" + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: c9177b2f3150ed0647b90a94307c28452fe0a5401863044216aed83addd6c7c5 + url: "https://pub.dev" + source: hosted + version: "2.4.2" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8802215..c4313d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,7 +109,7 @@ dependencies: pasteboard: 0.5.0 flutter_file_dialog: 3.3.3 mime: 2.1.0 - + flutter_avif: 3.1.0 dev_dependencies: build_runner: 2.16.2 flutter_lints: 6.0.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 95357c4..13fb733 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterAvifWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a7b2473..1822f53 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links dynamic_color file_selector_windows + flutter_avif_windows media_kit_libs_windows_video media_kit_video pasteboard From 58a27fdd9e7f6e8b5af90502100c4ded8815a74c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 22:00:00 -0400 Subject: [PATCH 08/21] tiny cleanup in avif decoding --- lib/helpers/mxc_image.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart index 83bcf14..1623029 100644 --- a/lib/helpers/mxc_image.dart +++ b/lib/helpers/mxc_image.dart @@ -33,7 +33,7 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) return AvifCodecAdapter(codec); } - return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); + return decode(await .fromUint8List(bytes)); } @override From ef25e765d48854d0e50dd59796686b3d7a9e50a2 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 22:12:15 -0400 Subject: [PATCH 09/21] handle events_decrypted --- lib/controllers/gomuks_listener.dart | 24 ++++++++++++++++++++++++ lib/models/events_decrypted.dart | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 lib/models/events_decrypted.dart diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index 62c16c5..bcf873a 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -17,6 +17,7 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:ffi/ffi.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/events_decrypted.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -60,6 +61,29 @@ class GomuksListenerController extends AsyncNotifier { event.roomId: .new(events: .new({event.rowId: event})), }), ); + case "events_decrypted": + final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); + final metadata = ref + .read(RoomsController.provider)[decrypted.roomId] + ?.metadata; + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + decrypted.roomId: .new( + events: decrypted.events, + metadata: metadata?.copyWith( + previewEventRowID: + decrypted.previewEventRowID ?? + metadata.previewEventRowID, + sortingTimestamp: + decrypted.sortingTimestamp ?? + metadata.sortingTimestamp, + ), + ), + }), + ); case "sync_complete": final syncData = SyncData.fromJson(decodedMuksEvent); final roomProvider = RoomsController.provider; diff --git a/lib/models/events_decrypted.dart b/lib/models/events_decrypted.dart new file mode 100644 index 0000000..f35c8aa --- /dev/null +++ b/lib/models/events_decrypted.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +part "events_decrypted.freezed.dart"; +part "events_decrypted.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const EventsDecrypted({ + required final String roomId, + @JsonKey(name: "preview_event_rowid") final int? previewEventRowID, + @EpochDateTimeConverter() final DateTime? sortingTimestamp, + + /// [events] is an IMap of eventRowId to event + @JsonKey(fromJson: Room.eventsJsonToIMap) + required final IMap events, +}) with _$EventsDecrypted { + factory EventsDecrypted.fromJson(Map json) => + _$EventsDecryptedFromJson(json); +} From 254b7e134fc8661debfdd1ba6b10ea707ced7643 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 22:14:15 -0400 Subject: [PATCH 10/21] release v1.0.0-alpha4 --- README.md | 2 +- linux/nix/pkg/default.nix | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66605b3..8a370c0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ nexus = { This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: ```nix -url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha3"; +url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha4"; ``` Then import the module and enable it in your NixOS configuration: diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index d7eb059..2c66fb8 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -12,7 +12,7 @@ flutter.buildFlutterApplication { pname = "nexus"; - version = "1.0.0-alpha3"; + version = "1.0.0-alpha4"; inherit src; preBuild = '' diff --git a/pubspec.yaml b/pubspec.yaml index c4313d4..b276bea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "A simple and user-friendly Matrix client" -version: 1.0.0-alpha3 +version: 1.0.0-alpha4 publish_to: none flutter: From 72c5e7d819ba57ded93eab69b476e24d9dc98990 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 10:24:30 -0400 Subject: [PATCH 11/21] add errorBuilder to reactions --- lib/widgets/reaction_row.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index edef81c..0b90781 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -53,6 +53,12 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { ref, .new(mxc: Uri.parse(reaction)), ), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + color: Theme.of(context) + .colorScheme + .error, + ), ) : Text(reaction, overflow: .ellipsis), ), From 8180338d99b4ceccafd3166918a69b0c08221474 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 11:18:23 -0400 Subject: [PATCH 12/21] use another avif library that works on aarch64-linux --- android/build.gradle.kts | 17 --- lib/helpers/mxc_image.dart | 42 +++-- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - linux/nix/devshell.nix | 17 +-- linux/nix/pkg/avif_native.nix | 49 ++++++ linux/nix/pkg/default.nix | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 - pubspec.lock | 144 +----------------- pubspec.yaml | 7 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 12 files changed, 94 insertions(+), 197 deletions(-) create mode 100644 linux/nix/pkg/avif_native.nix diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f82adc9..441eb71 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -16,23 +16,6 @@ subprojects { project.layout.buildDirectory.value(newSubprojectBuildDir) } -subprojects { - if (name == "flutter_avif_android") { - tasks.withType().configureEach { - exclude("**/FlutterAvifPlugin.kt") - } - afterEvaluate { - extensions.configure { - compileSdk = 36 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - } - } - } -} - subprojects { project.evaluationDependsOn(":app") } diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart index 1623029..4013397 100644 --- a/lib/helpers/mxc_image.dart +++ b/lib/helpers/mxc_image.dart @@ -1,8 +1,10 @@ +import "dart:async"; import "dart:io"; +import "dart:isolate"; import "dart:ui" as ui; +import "package:avif_native/avif_native.dart"; import "package:flutter/widgets.dart"; -import "package:flutter_avif/flutter_avif.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/models/requests/download_media.dart"; @@ -24,13 +26,9 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) final bytes = await File(file.path).readAsBytes(); // Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves - final avifType = isAvifFile(bytes.sublist(0, 16.clamp(0, bytes.length))); - if (avifType != .unknown) { - final codec = avifType == .avif - ? SingleFrameAvifCodec(bytes: bytes) - : MultiFrameAvifCodec(key: hashCode, avifBytes: bytes); - await codec.ready(); - return AvifCodecAdapter(codec); + if (String.fromCharCodes(bytes.skip(4).take(8)) + case "ftypavif" || "ftypavis") { + return AvifCodec(await Isolate.run(() => decodeAvif(bytes))); } return decode(await .fromUint8List(bytes)); @@ -44,21 +42,33 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) int get hashCode => request.hashCode; } -class AvifCodecAdapter(final AvifCodec codec) implements ui.Codec { - @override - int get frameCount => codec.frameCount; +class AvifCodec(final AvifImageData data) implements ui.Codec { + var _frameIndex = 0; @override - int get repetitionCount => frameCount > 1 ? -1 : 0; + int get frameCount => data.frames.length; + + // libavif uses -2 for unknown, which we treat as infinite like browsers do + @override + int get repetitionCount => + data.repetitionCount < 0 ? -1 : data.repetitionCount; @override - Future getNextFrame() async { - final frame = await codec.getNextFrame(); - return AvifFrameInfo(frame.duration, frame.image); + Future getNextFrame() { + final frame = data.frames[_frameIndex++ % frameCount]; + final completer = Completer(); + ui.decodeImageFromPixels( + frame.rgba, + frame.width, + frame.height, + .rgba8888, + (image) => completer.complete(AvifFrameInfo(frame.duration, image)), + ); + return completer.future; } @override - void dispose() => codec.dispose(); + void dispose() {} } class AvifFrameInfo( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d95018b..b2ee366 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -28,9 +27,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); - flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d212bd..eea220b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links_linux dynamic_color file_selector_linux - flutter_avif_linux media_kit_libs_linux media_kit_video pasteboard diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index bfa5408..6b2b94d 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -9,7 +9,6 @@ let ]; cmakeVersions = [ "3.22.1" ]; platformVersions = [ - "31" "34" "36" ]; @@ -48,17 +47,7 @@ pkgs.mkShell { GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; } - // lib.optionalAttrs pkgs.stdenv.isLinux ( - let - arch = if pkgs.stdenv.hostPlatform.isAarch64 then "arm64" else "x64"; - in - { - LD_LIBRARY_PATH = lib.concatStringsSep ":" [ - "./build/native_assets/linux" - "./build/linux/${arch}/debug/bundle/lib" - "./build/linux/${arch}/release/bundle/lib" - (lib.makeLibraryPath [ pkgs.zlib ]) - ]; - } - ); + // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; } diff --git a/linux/nix/pkg/avif_native.nix b/linux/nix/pkg/avif_native.nix new file mode 100644 index 0000000..16d0fc0 --- /dev/null +++ b/linux/nix/pkg/avif_native.nix @@ -0,0 +1,49 @@ +# avif_native's build hook downloads its prebuilt library, which the sandbox +# can't do, so bundle it and point the manifest at the local copy instead +{ + stdenv, + fetchurl, + jq, +}: +let + artifacts = { + x86_64-linux = { + arch = "x64"; + sha256 = "252b064f8ff4f0f265bc44294d4a7ed703dd7199688942c272dd8aa8d3039d5a"; + }; + aarch64-linux = { + arch = "arm64"; + sha256 = "cdcf17cfd18b9b0f965b72615ddf11246121114577dc570d0bba25964e56c726"; + }; + }; + inherit (artifacts.${stdenv.hostPlatform.system}) arch sha256; + key = "linux-${arch}-decode-dav1d"; +in +{ version, src, ... }: +stdenv.mkDerivation { + pname = "avif_native"; + inherit version src; + inherit (src) passthru; + + nativeBuildInputs = [ jq ]; + + library = fetchurl { + url = "https://github.com/Zhangw1998/avif_native/releases/download/native-v0.1.0/${key}-libavif_native.so.gz"; + inherit sha256; + }; + + # The hook checksums the library, so it must stay byte-identical + dontFixup = true; + + installPhase = '' + runHook preInstall + + cp -r . $out + chmod -R u+w $out + gunzip -c $library > $out/native/libavif_native.so + jq '.artifacts["${key}"].path = "libavif_native.so"' \ + native/manifest.json > $out/native/manifest.json + + runHook postInstall + ''; +} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 2c66fb8..f2bbbff 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -34,6 +34,8 @@ flutter.buildFlutterApplication { autoPubspecLock = src + "/pubspec.lock"; + customSourceBuilders.avif_native = callPackage ./avif_native.nix { }; + gitHashes = { linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c801836..2a15f97 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import app_links import dynamic_color import file_selector_macos -import flutter_avif_macos import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video @@ -16,7 +15,6 @@ import package_info_plus import pasteboard import screen_retriever_macos import shared_preferences_foundation -import sqflite_darwin import url_launcher_macos import wakelock_plus import webcrypto @@ -26,7 +24,6 @@ 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")) - FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) @@ -34,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index bf9c326..1958e04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + avif_native: + dependency: "direct main" + description: + name: avif_native + sha256: "0a374a7ddc6e48d671792bd33efd9f46f199389cdcd42561210bd886facbc62e" + url: "https://pub.dev" + source: hosted + version: "0.1.1" boolean_selector: dependency: transitive description: @@ -313,14 +321,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - exif: - dependency: transitive - description: - name: exif - sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 - url: "https://pub.dev" - source: hosted - version: "3.3.0" fab_m3e: dependency: "direct overridden" description: @@ -455,70 +455,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_avif: - dependency: "direct main" - description: - name: flutter_avif - sha256: "6035f073189c1ae134affa73338c2eca79bf412e9abdb8f8628fdefcccff4444" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_android: - dependency: transitive - description: - name: flutter_avif_android - sha256: "1da135ea0d74225fae3154f954f9ba4cf7aa5e56b30d9851fcfc0ddecab9d8c5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_ios: - dependency: transitive - description: - name: flutter_avif_ios - sha256: "3daaa599d8fe0193d3ade6cafa1fd4f166d4063b8d499c7c311ce1d170b2759f" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_linux: - dependency: transitive - description: - name: flutter_avif_linux - sha256: dd6179edca12c720761b3acc678d7f15208eb8647adbac5a6a90f52d4ba56e2d - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_macos: - dependency: transitive - description: - name: flutter_avif_macos - sha256: "8e39f365dfc5713d4527f07a92399184857ea30eced85f82171bfd0a45e2c73b" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_platform_interface: - dependency: transitive - description: - name: flutter_avif_platform_interface - sha256: f5c110bdb7de2d4ab4902bfe2ec331fe24bc19b6d54f00ac4fa165effc0edcda - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_web: - dependency: transitive - description: - name: flutter_avif_web - sha256: db2ff395b08cbfe9116e48ac65c428ba21a9159972ea019f8d7be580ff6cf6e8 - url: "https://pub.dev" - source: hosted - version: "3.1.0" - flutter_avif_windows: - dependency: transitive - description: - name: flutter_avif_windows - sha256: e0c7722df305a6621f4ecdd83098025c705bcf0e8ce9519e03c6f515abcf351d - url: "https://pub.dev" - source: hosted - version: "3.1.0" flutter_blurhash: dependency: "direct main" description: @@ -527,14 +463,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.1" - flutter_cache_manager: - dependency: transitive - description: - name: flutter_cache_manager - sha256: f94fc6f5480977e3eb7b622ed488f671ba96c971bd0e274e6bd0f0ba9b044816 - url: "https://pub.dev" - source: hosted - version: "3.4.5" flutter_file_dialog: dependency: "direct main" description: @@ -1265,14 +1193,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" pub_semver: dependency: transitive description: @@ -1486,54 +1406,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "273218a1678360a3127fcf15e543381d9b813d413d783bc6e9a53eed4d2eb8fe" - url: "https://pub.dev" - source: hosted - version: "2.4.4+2" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: e4f659c14bdd14464127fcd234a7a96f61fe0f539b8568e5a38f99f5a568a835 - url: "https://pub.dev" - source: hosted - version: "2.4.4+1" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 - url: "https://pub.dev" - source: hosted - version: "2.5.14" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "8de68fc306040f992aaf378750c16f95e4298c4bb150dd45091d75e42b1f08ee" - url: "https://pub.dev" - source: hosted - version: "2.4.4+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: c9177b2f3150ed0647b90a94307c28452fe0a5401863044216aed83addd6c7c5 - url: "https://pub.dev" - source: hosted - version: "2.4.2" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b276bea..c93eaac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,7 +109,7 @@ dependencies: pasteboard: 0.5.0 flutter_file_dialog: 3.3.3 mime: 2.1.0 - flutter_avif: 3.1.0 + avif_native: 0.1.1 dev_dependencies: build_runner: 2.16.2 flutter_lints: 6.0.0 @@ -133,3 +133,8 @@ flutter_launcher_icons: macos: generate: true image_path: assets/mobile.png + +hooks: + user_defines: + avif_native: + features: decode diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 13fb733..95357c4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -25,8 +24,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterAvifWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1822f53..a7b2473 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links dynamic_color file_selector_windows - flutter_avif_windows media_kit_libs_windows_video media_kit_video pasteboard From 200e03f082775f0828b212e82df7851c3e648e8d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 11:21:20 -0400 Subject: [PATCH 13/21] noheic always --- hook/build.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hook/build.dart b/hook/build.dart index 9315e04..8726642 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -122,7 +122,7 @@ Future main(List args) => build(args, (input, output) async { "sqlite_fts5", "goolm", // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream - if (targetOS == OS.android || targetOS == OS.iOS) "noheic", + "noheic", ].join(","); final archiveFile = targetOS == OS.iOS From 4e6ba571fcb21b2383a33ba238028be31f5a9ed9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 11:37:51 -0400 Subject: [PATCH 14/21] override webcrypto to a version with build hooks --- README.md | 6 ---- linux/flutter/generated_plugin_registrant.cc | 4 --- linux/flutter/generated_plugins.cmake | 1 - linux/nix/pkg/default.nix | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 -- pubspec.lock | 29 +++++++++++++++---- pubspec.yaml | 7 +++-- .../flutter/generated_plugin_registrant.cc | 3 -- windows/flutter/generated_plugins.cmake | 1 - 9 files changed, 29 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8a370c0..a106b47 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,6 @@ 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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b2ee366..b92986a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -14,7 +14,6 @@ #include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -42,9 +41,6 @@ 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 eea220b..553a7ae 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST pasteboard screen_retriever_linux url_launcher_linux - webcrypto window_manager ) diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index f2bbbff..e216a5c 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -43,6 +43,7 @@ flutter.buildFlutterApplication { navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; + webcrypto = "sha256-jC5hTIcfnhqm/rD9myrfXfA5+Wkf5CfTYjtAnlT4FN4="; xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; }; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2a15f97..cc20897 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,7 +17,6 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus -import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -33,6 +32,5 @@ 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 1958e04..c9c2135 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: @@ -1024,6 +1032,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.5" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: "8ba223410102665483e873b83d2156720233a6a0181c07f9dc41cb2e961336b0" + url: "https://pub.dev" + source: hosted + version: "0.3.2" navigation_rail_m3e: dependency: "direct main" description: @@ -1729,13 +1745,14 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct dev" + dependency: "direct overridden" description: - name: webcrypto - sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" - url: "https://pub.dev" - source: hosted - version: "0.6.0" + path: "." + ref: da6648cf87309af4271e312c447e0356f8fda5d1 + resolved-ref: da6648cf87309af4271e312c447e0356f8fda5d1 + url: "https://github.com/google/webcrypto.dart" + source: git + version: "0.6.2-wip" webpush_encryption: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c93eaac..15b5db7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,11 @@ environment: dependency_overrides: path_provider_android: 2.2.23 # Pinned to avoid JNI dynamic_color: 2.1.0 + code_assets: 2.1.0 # webcrypto requires ^1.0.0, but works with 2.x + webcrypto: + git: + url: https://github.com/google/webcrypto.dart + ref: da6648cf87309af4271e312c447e0356f8fda5d1 # Uses build hooks, unreleased m3e_design: git: url: https://github.com/Henry-Hiles/material_3_expressive @@ -117,8 +122,6 @@ dev_dependencies: riverpod_lint: 3.1.9 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 - # Transitive dep but needed for commands - webcrypto: 0.6.0 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 95357c4..1d53353 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -14,7 +14,6 @@ #include #include #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -34,8 +33,6 @@ 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 a7b2473..05e3fad 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST pasteboard screen_retriever_windows url_launcher_windows - webcrypto window_manager ) From eca2b0b5587e66c5fbc909aacf6a8c2c67bef1bc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 11:53:23 -0400 Subject: [PATCH 15/21] Revert "override webcrypto to a version with build hooks" This reverts commit 4e6ba571fcb21b2383a33ba238028be31f5a9ed9. Due to an upstream bug, this broke Linux aarch64 builds, as well as MacOS builds. --- README.md | 6 ++++ linux/flutter/generated_plugin_registrant.cc | 4 +++ linux/flutter/generated_plugins.cmake | 1 + linux/nix/pkg/default.nix | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ pubspec.lock | 29 ++++--------------- pubspec.yaml | 7 ++--- .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 9 files changed, 25 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a106b47..8a370c0 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b92986a..b2ee366 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -41,6 +42,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 553a7ae..eea220b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST pasteboard screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index e216a5c..f2bbbff 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -43,7 +43,6 @@ flutter.buildFlutterApplication { navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; - webcrypto = "sha256-jC5hTIcfnhqm/rD9myrfXfA5+Wkf5CfTYjtAnlT4FN4="; xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; }; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cc20897..2a15f97 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,6 +17,7 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -32,5 +33,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 c9c2135..1958e04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,14 +177,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" - change_case: - dependency: transitive - description: - name: change_case - sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 - url: "https://pub.dev" - source: hosted - version: "2.2.0" characters: dependency: transitive description: @@ -1032,14 +1024,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.5" - native_toolchain_cmake: - dependency: transitive - description: - name: native_toolchain_cmake - sha256: "8ba223410102665483e873b83d2156720233a6a0181c07f9dc41cb2e961336b0" - url: "https://pub.dev" - source: hosted - version: "0.3.2" navigation_rail_m3e: dependency: "direct main" description: @@ -1745,14 +1729,13 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct overridden" + dependency: "direct dev" description: - path: "." - ref: da6648cf87309af4271e312c447e0356f8fda5d1 - resolved-ref: da6648cf87309af4271e312c447e0356f8fda5d1 - url: "https://github.com/google/webcrypto.dart" - source: git - version: "0.6.2-wip" + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + url: "https://pub.dev" + source: hosted + version: "0.6.0" webpush_encryption: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 15b5db7..c93eaac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,11 +24,6 @@ environment: dependency_overrides: path_provider_android: 2.2.23 # Pinned to avoid JNI dynamic_color: 2.1.0 - code_assets: 2.1.0 # webcrypto requires ^1.0.0, but works with 2.x - webcrypto: - git: - url: https://github.com/google/webcrypto.dart - ref: da6648cf87309af4271e312c447e0356f8fda5d1 # Uses build hooks, unreleased m3e_design: git: url: https://github.com/Henry-Hiles/material_3_expressive @@ -122,6 +117,8 @@ dev_dependencies: riverpod_lint: 3.1.9 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 + # Transitive dep but needed for commands + webcrypto: 0.6.0 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 1d53353..95357c4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -33,6 +34,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 05e3fad..a7b2473 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST pasteboard screen_retriever_windows url_launcher_windows + webcrypto window_manager ) From 9f45cbc1e7bd71af281ac8051738058ac817744b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 16:12:10 -0400 Subject: [PATCH 16/21] add warning when user has cross signing but not secret storage --- lib/widgets/reset_encryption_sheet.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/widgets/reset_encryption_sheet.dart b/lib/widgets/reset_encryption_sheet.dart index a3044a1..8482a8f 100644 --- a/lib/widgets/reset_encryption_sheet.dart +++ b/lib/widgets/reset_encryption_sheet.dart @@ -5,6 +5,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/client_state.dart"; import "package:nexus/widgets/recovery_key_sheet.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -16,11 +17,12 @@ class const ResetEncryptionSheet({ }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isReset = ref.watch( + final verificationState = ref.watch( ClientStateController.provider.select( - (state) => state?.verificationState.hasCrossSigning ?? false, + (state) => state?.verificationState, ), ); + final isReset = verificationState?.hasCrossSigning ?? false; final usePassphrase = useState(false); final passphraseController = useTextEditingController(); @@ -95,6 +97,13 @@ class const ResetEncryptionSheet({ ), ], children: [ + if (verificationState case VerificationState( + hasCrossSigning: true, + hasSecretStorage: false, + )) + const Text( + "Your account has an encryption identity, but no recovery key. If you're still signed in on another client, you can set up a recovery key there instead, then restart Nexus and use it to verify. Otherwise, continue with the reset:", + ), Text( isReset ? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable." From a6fe80fea294588fee8bfeb319bed70d2160bb8b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 17 Oct 2026 16:19:27 -0400 Subject: [PATCH 17/21] split APKs per abi and only upload arm64 APK --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b9b6207..cf64422 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,7 +27,7 @@ jobs: KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --dart-define=GIT_COMMIT=${{ github.sha }}" + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}" env: KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} @@ -36,4 +36,4 @@ jobs: uses: actions/upload-artifact@v6 with: name: APK - path: build/app/outputs/flutter-apk/app-release.apk + path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk From d1724932b2fda566bfd88965c97658cc0e283a08 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 18 Oct 2026 16:01:37 -0400 Subject: [PATCH 18/21] clean up and improve notifications Thanks to Gomuks' new handling, we have sender displayname and avatar, and can use android's conversation notifications. On other platforms, we just append sender name to the body, bolded if possible. This also improves the logic on when to send notifications, as we drop notifs where `response["notification"]` is null. We also now correctly set if notifications should play a sound or not, based on `PushNotification.sound`. `PushNotification.image` handling might come sometime in the future, but it seems hard on Android. --- gomuks | 2 +- lib/controllers/client.dart | 11 ++---- lib/controllers/notification.dart | 63 +++++++++++++++++++++++++++---- lib/controllers/unified_push.dart | 58 +++++++++++++++++----------- lib/models/push_notification.dart | 44 +++++++++++++++++++++ 5 files changed, 139 insertions(+), 39 deletions(-) create mode 100644 lib/models/push_notification.dart diff --git a/gomuks b/gomuks index ad275f7..7e43a63 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9 +Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index a282f81..52b49ee 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -31,6 +31,7 @@ import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; +import "package:nexus/models/push_notification.dart"; import "package:nexus/models/recovery_key.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; @@ -47,7 +48,6 @@ import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/models/room_metadata.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/spec_versions_response.dart"; @@ -128,18 +128,15 @@ class ClientController extends AsyncNotifier { } } - Future<(Event, RoomMetadata)?> handlePush(Map data) async { + Future handlePush(Map data) async { final response = await callGomuksMethod( data, (handle, data) async => GomuksHandlePush(handle, data), ); - return response == null + return response?["notification"] == null ? null - : ( - Event.fromJson(response["event"]), - RoomMetadata.fromJson(response["room"]), - ); + : .fromJson(response["notification"]); } Future _sendCommand( diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 27c2f91..4016a2c 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "dart:convert"; import "dart:io"; import "package:flutter/services.dart"; @@ -109,18 +110,32 @@ class NotificationController String? body, File? icon, String? payload, + String? senderName, + File? senderAvatar, + String? selfName, + bool isGroup = false, + bool sound = true, + DateTime? timestamp, }) async { debugPrint("Sending notification for $id"); + // In DMs the title is already the sender, so only prefix in groups + final prefix = isGroup ? senderName : null; + if (Platform.isLinux) { - final portal = await ref.watch(PortalController.provider.future); + final portal = await ref.read(PortalController.provider.future); + const escape = HtmlEscape(HtmlEscapeMode.element); await portal.notification.addNotification( id.toString(), title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", + markupBody: prefix == null || body == null + ? null + : "${escape.convert(prefix)}: ${escape.convert(body)}", defaultAction: "app.event", defaultActionTarget: payload, + sound: sound ? null : const XdgNotificationSoundSilent(), category: XdgNotificationCategory.imReceived, priority: .normal, buttons: [ @@ -133,25 +148,57 @@ class NotificationController ], ); } else { + final controller = await future; + + // Append to the conversation already shown under this id, if any + final existingStyle = Platform.isAndroid + ? await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.getActiveNotificationMessagingStyle(id: id) + .onError((_, _) => null) + : null; + + final darwin = DarwinNotificationDetails(presentSound: sound); final notificationDetails = NotificationDetails( android: .new( "messages", "Messages", + silent: !sound, largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + styleInformation: senderName == null || body == null + ? null + : MessagingStyleInformation( + .new(name: selfName ?? "You"), + conversationTitle: isGroup ? title : null, + groupConversation: isGroup, + messages: [ + ...?existingStyle?.messages, + .new( + body, + timestamp ?? .now(), + .new( + name: senderName, + icon: senderAvatar == null + ? null + : BitmapFilePathAndroidIcon(senderAvatar.path), + ), + ), + ], + ), ), // TODO: See if icons can be added to iOS, macOS, and Windows // notifications (#68) - iOS: .new(), - macOS: .new(), - windows: .new(), + iOS: darwin, + macOS: darwin, + windows: .new(audio: sound ? null : .silent()), ); - final controller = await future; - await controller.show( id: id, title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", notificationDetails: notificationDetails, payload: payload, ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 8dcb295..87ec4e8 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -11,8 +11,6 @@ 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/models/content/message.dart"; -import "package:nexus/models/content/sticker.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:window_manager/window_manager.dart"; @@ -79,42 +77,56 @@ class UnifiedPushController extends AsyncNotifier { "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", ); } - final pushResponse = await ref + final notification = await ref .read(ClientController.provider.notifier) .handlePush(json.decode(String.fromCharCodes(message.content))); - if (pushResponse == null) return; - - final (event, roomMetadata) = pushResponse; - - if (event.unreadType?.has(.notify) != true || + if (notification == null || (!isInBackground && await windowManager.isFocused().onError((_, _) => true) && - ref.read(SelectedRoomController.provider) == event.roomId)) { + ref.read(SelectedRoomController.provider) == + notification.roomId)) { if (isInBackground) exit(0); return; } - final icon = roomMetadata.avatar == null + final client = ref.read(ClientController.provider.notifier); + + final icon = notification.roomAvatar == null ? null - : await ref - .read(ClientController.provider.notifier) - .downloadMedia( - .new(mxc: roomMetadata.avatar!, isAvatar: true), - ); + : await client.downloadMedia( + .new(mxc: notification.roomAvatar!, isAvatar: true), + ); + + final pmp = notification.perMessageProfile; + final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar; + // Only Android can show the sender's avatar + final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid + ? null + : await client.downloadMedia( + .new(mxc: senderAvatarUrl, isAvatar: true), + ); await ref .read(NotificationController.provider.notifier) .send( - id: event.eventId.hashCode & 0x7fffffff, - title: roomMetadata.name ?? "New Event", + // Android stacks messages into one conversation per room + id: + (Platform.isAndroid + ? notification.roomId + : notification.eventId) + .hashCode & + 0x7fffffff, + title: notification.roomName, icon: icon, - payload: event.rowId.toString(), - body: switch (event.content) { - MessageContent(:final body?) || - StickerContent(:final body) => body, - _ => null, - }, + payload: notification.eventRowId.toString(), + body: notification.text, + senderName: pmp?.name ?? notification.sender.name, + senderAvatar: senderAvatar, + selfName: notification.self.name, + isGroup: !notification.isDm, + sound: notification.sound, + timestamp: notification.timestamp, ); if (isInBackground) exit(0); diff --git a/lib/models/push_notification.dart b/lib/models/push_notification.dart new file mode 100644 index 0000000..859b457 --- /dev/null +++ b/lib/models/push_notification.dart @@ -0,0 +1,44 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "push_notification.freezed.dart"; +part "push_notification.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PushNotification({ + @EpochDateTimeConverter() required final DateTime timestamp, + + required final String eventId, + @JsonKey(name: "event_rowid") required final int eventRowId, + + required final String roomId, + required final String roomName, + final Uri? roomAvatar, + final bool isDm = false, + + required final NotificationUser sender, + required final NotificationUser self, + final NotificationUser? perMessageProfile, + + required final String text, + final Uri? image, + + final bool mention = false, + final bool reply = false, + final bool sound = false, +}) with _$PushNotification { + factory PushNotification.fromJson(Map json) => + _$PushNotificationFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const NotificationUser({ + required final String id, + required final String name, + final Uri? avatar, +}) with _$NotificationUser { + factory NotificationUser.fromJson(Map json) => + _$NotificationUserFromJson(json); +} From 4580110da079f05945103d50ae8e7931e8417616 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 18 Oct 2026 16:04:56 -0400 Subject: [PATCH 19/21] update gomuks vendorHash --- linux/nix/pkg/gomuks.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 604f5be..6c383d2 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -17,7 +17,7 @@ buildGoModule ( src = "${src}/gomuks"; - vendorHash = "sha256-+0iFK2ribhiNskXgPz7vslncuOgPMB/ZmFSfDOcz6ww="; + vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; buildPhase = '' runHook preBuild From e24e99fd8eb8ee8f8c365107f7e0fcdd1a3b9faa Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 18 Oct 2026 16:14:00 -0400 Subject: [PATCH 20/21] slight improvements to windows and darwin notifs --- lib/controllers/notification.dart | 16 ++++++++++++---- lib/controllers/unified_push.dart | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 4016a2c..7b5d2b8 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -116,6 +116,7 @@ class NotificationController bool isGroup = false, bool sound = true, DateTime? timestamp, + String? threadId, }) async { debugPrint("Sending notification for $id"); @@ -160,7 +161,10 @@ class NotificationController .onError((_, _) => null) : null; - final darwin = DarwinNotificationDetails(presentSound: sound); + final darwin = DarwinNotificationDetails( + presentSound: sound, + threadIdentifier: threadId, + ); final notificationDetails = NotificationDetails( android: .new( "messages", @@ -188,11 +192,15 @@ class NotificationController ], ), ), - // TODO: See if icons can be added to iOS, macOS, and Windows - // notifications (#68) iOS: darwin, macOS: darwin, - windows: .new(audio: sound ? null : .silent()), + windows: .new( + audio: sound ? null : .silent(), + timestamp: timestamp, + header: threadId == null + ? null + : .new(id: threadId, title: title, arguments: payload ?? ""), + ), ); await controller.show( diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 87ec4e8..333ecc6 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -127,6 +127,7 @@ class UnifiedPushController extends AsyncNotifier { isGroup: !notification.isDm, sound: notification.sound, timestamp: notification.timestamp, + threadId: notification.roomId, ); if (isInBackground) exit(0); From deac9ef24e5f2a1f0bc85f2a4570537078cda81f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 18 Oct 2026 16:36:36 -0400 Subject: [PATCH 21/21] 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, ), ), );