From 5f71103956831235be909816d167e3460ce1d89d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 22:13:20 -0400 Subject: [PATCH 01/13] release v1.0.0-alpha4 Co-Authored-By: Claude Opus 5.5 --- 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 254b7e134fc8661debfdd1ba6b10ea707ced7643 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 16 Oct 2026 22:14:15 -0400 Subject: [PATCH 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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, ), ), );