Compare commits

..
Author SHA1 Message Date
5f71103956
release v1.0.0-alpha4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:13:20 -04:00
25 changed files with 314 additions and 387 deletions

View file

@ -27,7 +27,7 @@ jobs:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app - 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 --split-per-abi --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 --dart-define=GIT_COMMIT=${{ github.sha }}"
env: env:
KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@ -36,4 +36,4 @@ jobs:
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: APK name: APK
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk path: build/app/outputs/flutter-apk/app-release.apk

View file

@ -16,6 +16,23 @@ subprojects {
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
subprojects {
if (name == "flutter_avif_android") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
exclude("**/FlutterAvifPlugin.kt")
}
afterEvaluate {
extensions.configure<com.android.build.api.dsl.LibraryExtension> {
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
}
}
}
subprojects { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }

2
gomuks

@ -1 +1 @@
Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9

View file

@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"sqlite_fts5", "sqlite_fts5",
"goolm", "goolm",
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
"noheic", if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
].join(","); ].join(",");
final archiveFile = targetOS == OS.iOS final archiveFile = targetOS == OS.iOS

View file

@ -31,7 +31,6 @@ import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.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/recovery_key.dart";
import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
@ -48,6 +47,7 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.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/room_summary.dart";
import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/models/spec_versions_response.dart";
@ -128,15 +128,18 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<PushNotification?> handlePush(Map<String, dynamic> data) async { Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod( final response = await callGomuksMethod(
data, data,
(handle, data) async => GomuksHandlePush(handle, data), (handle, data) async => GomuksHandlePush(handle, data),
); );
return response?["notification"] == null return response == null
? null ? null
: .fromJson(response["notification"]); : (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
} }
Future<dynamic> _sendCommand( Future<dynamic> _sendCommand(

View file

@ -143,19 +143,6 @@ class GomuksListenerController extends AsyncNotifier<void> {
} }
} }
/// Waits for gomuks to report the client's device ID
Future<String> deviceId() {
final completer = Completer<String>();
final subscription = ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, next) {
if (next != null && !completer.isCompleted) completer.complete(next);
},
fireImmediately: true,
);
return completer.future.whenComplete(subscription.close);
}
static Future<int> _start( static Future<int> _start(
int handle, int handle,
Pointer< Pointer<

View file

@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "dart:convert";
import "dart:io"; import "dart:io";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
@ -104,75 +103,24 @@ class NotificationController
return true; return true;
} }
/// Whether notifications can currently be shown, without prompting
Future<bool> permissionsGranted() async {
if (Platform.isLinux) {
return true;
}
final controller = await future;
if (Platform.isIOS) {
return (await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.checkPermissions())
?.isEnabled ??
true;
} else if (Platform.isMacOS) {
return (await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.checkPermissions())
?.isEnabled ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.areNotificationsEnabled() ??
true;
}
return true;
}
Future<void> send({ Future<void> send({
required int id, required int id,
required String title, required String title,
String? body, String? body,
File? icon, File? icon,
String? payload, String? payload,
String? senderName,
File? senderAvatar,
String? selfName,
bool isGroup = false,
bool sound = true,
DateTime? timestamp,
String? threadId,
}) async { }) async {
debugPrint("Sending notification for $id"); 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) { if (Platform.isLinux) {
final portal = await ref.read(PortalController.provider.future); final portal = await ref.watch(PortalController.provider.future);
const escape = HtmlEscape(HtmlEscapeMode.element);
await portal.notification.addNotification( await portal.notification.addNotification(
id.toString(), id.toString(),
title: title, title: title,
body: prefix == null || body == null ? body : "$prefix: $body", body: body,
markupBody: prefix == null || body == null
? null
: "<b>${escape.convert(prefix)}</b>: ${escape.convert(body)}",
defaultAction: "app.event", defaultAction: "app.event",
defaultActionTarget: payload, defaultActionTarget: payload,
sound: sound ? null : const XdgNotificationSoundSilent(),
category: XdgNotificationCategory.imReceived, category: XdgNotificationCategory.imReceived,
priority: .normal, priority: .normal,
buttons: [ buttons: [
@ -185,64 +133,25 @@ class NotificationController
], ],
); );
} else { } 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,
threadIdentifier: threadId,
);
final notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: .new( android: .new(
"messages", "messages",
"Messages", "Messages",
silent: !sound,
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), 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),
),
),
],
),
),
iOS: darwin,
macOS: darwin,
windows: .new(
audio: sound ? null : .silent(),
timestamp: timestamp,
header: threadId == null
? null
: .new(id: threadId, title: title, arguments: payload ?? ""),
), ),
// TODO: See if icons can be added to iOS, macOS, and Windows
// notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
); );
final controller = await future;
await controller.show( await controller.show(
id: id, id: id,
title: title, title: title,
body: prefix == null || body == null ? body : "$prefix: $body", body: body,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
payload: payload, payload: payload,
); );

View file

@ -46,7 +46,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: settings.theme, initialValue: settings.theme,
options: ThemeMode.values, options: ThemeMode.values,
getName: (option) => toBeginningOfSentenceCase(option.name), getName: (option) => toBeginningOfSentenceCase(option.name),
onChanged: (value) => ref onChanged: (value) => ref
@ -105,7 +105,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: settings.roomOrder, initialValue: settings.roomOrder,
options: RoomOrder.values, options: RoomOrder.values,
getName: (option) => switch (option) { getName: (option) => switch (option) {
.unreadFirst => "Mentioned, then unread", .unreadFirst => "Mentioned, then unread",
@ -194,6 +194,7 @@ class SettingsSectionsController
final current = pusherRegistered.value; final current = pusherRegistered.value;
return DialogListTile<String?>( return DialogListTile<String?>(
key: ValueKey(current),
title: title, title: title,
subtitle: Text( subtitle: Text(
unifiedPushAllowed.maybeWhen( unifiedPushAllowed.maybeWhen(
@ -203,7 +204,8 @@ class SettingsSectionsController
description, description,
), ),
icon: Icon(icon), icon: Icon(icon),
value: current, required: false,
initialValue: current,
options: [ options: [
null, null,
...?distributors.value, ...?distributors.value,
@ -225,32 +227,21 @@ class SettingsSectionsController
try { try {
loading.value = true; loading.value = true;
if (value != null) { if (value != null) {
final messenger = ScaffoldMessenger.of( if (await ref
context,
);
if (!await ref
.read( .read(
NotificationController NotificationController
.provider .provider
.notifier, .notifier,
) )
.requestPermissions()) { .requestPermissions()) {
messenger.showSnackBar( await ref
.new( .read(
content: Text( UnifiedPushController
"Notifications are disabled. Enable them for Nexus in your system settings.", .provider
), .notifier,
), )
); .register(value);
return;
} }
await ref
.read(
UnifiedPushController
.provider
.notifier,
)
.register(value);
} else { } else {
await ref await ref
.read( .read(
@ -292,7 +283,7 @@ class SettingsSectionsController
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
value: ref initialValue: ref
.watch(AccountDataController.provider) .watch(AccountDataController.provider)
.invitePermissionConfig .invitePermissionConfig
.defaultAction, .defaultAction,

View file

@ -10,7 +10,9 @@ import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/gomuks_listener.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/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
@ -72,67 +74,47 @@ class UnifiedPushController extends AsyncNotifier<String?> {
}, },
onMessage: (message, instance) async { onMessage: (message, instance) async {
debugPrint("UP message received for $instance"); debugPrint("UP message received for $instance");
if (await deregisterIfDisabled(instance)) {
if (isInBackground) exit(0);
return;
}
if (message.decrypted == false) { if (message.decrypted == false) {
throw Exception( throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
); );
} }
final notification = await ref final pushResponse = await ref
.read(ClientController.provider.notifier) .read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content))); .handlePush(json.decode(String.fromCharCodes(message.content)));
if (notification == null || if (pushResponse == null) return;
final (event, roomMetadata) = pushResponse;
if (event.unreadType?.has(.notify) != true ||
(!isInBackground && (!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) && await windowManager.isFocused().onError((_, _) => true) &&
ref.read(SelectedRoomController.provider) == ref.read(SelectedRoomController.provider) == event.roomId)) {
notification.roomId)) {
if (isInBackground) exit(0); if (isInBackground) exit(0);
return; return;
} }
final client = ref.read(ClientController.provider.notifier); final icon = roomMetadata.avatar == null
final icon = notification.roomAvatar == null
? null ? null
: await client.downloadMedia( : await ref
.new(mxc: notification.roomAvatar!, isAvatar: true), .read(ClientController.provider.notifier)
); .downloadMedia(
.new(mxc: roomMetadata.avatar!, 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 await ref
.read(NotificationController.provider.notifier) .read(NotificationController.provider.notifier)
.send( .send(
// Android stacks messages into one conversation per room id: event.eventId.hashCode & 0x7fffffff,
id: title: roomMetadata.name ?? "New Event",
(Platform.isAndroid
? notification.roomId
: notification.eventId)
.hashCode &
0x7fffffff,
title: notification.roomName,
icon: icon, icon: icon,
payload: notification.eventRowId.toString(), payload: event.rowId.toString(),
body: notification.text, body: switch (event.content) {
senderName: pmp?.name ?? notification.sender.name, MessageContent(:final body?) ||
senderAvatar: senderAvatar, StickerContent(:final body) => body,
selfName: notification.self.name, _ => null,
isGroup: !notification.isDm, },
sound: notification.sound,
timestamp: notification.timestamp,
threadId: notification.roomId,
); );
if (isInBackground) exit(0); if (isInBackground) exit(0);
@ -140,11 +122,9 @@ class UnifiedPushController extends AsyncNotifier<String?> {
onUnregistered: deregister, onUnregistered: deregister,
); );
if (!registered || isInBackground) return null; if (!registered) return null;
try { try {
if (await deregisterIfDisabled()) return null;
return await register(); return await register();
} catch (error, stackTrace) { } catch (error, stackTrace) {
showError(error, stackTrace); showError(error, stackTrace);
@ -163,9 +143,19 @@ class UnifiedPushController extends AsyncNotifier<String?> {
state = .loading(); state = .loading();
try { try {
final deviceId = await ref final deviceIdCompleter = Completer<String>();
.read(GomuksListenerController.provider.notifier) final subscription = ref.listen(
.deviceId(); 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,
);
await Future(() async { await Future(() async {
final capabilities = await ref final capabilities = await ref
@ -211,27 +201,12 @@ class UnifiedPushController extends AsyncNotifier<String?> {
} }
} }
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
if (await ref
.read(NotificationController.provider.notifier)
.permissionsGranted()) {
return false;
}
final instance =
notificationInstance ??
await ref.read(GomuksListenerController.provider.notifier).deviceId();
final key = await ref.read(PushKeyController.provider(instance).future);
if (key != null) await deregister(instance);
return true;
}
Future<void> deregister([String? instance]) async { Future<void> deregister([String? instance]) async {
instance ??= await ref final clientState = ref.read(ClientStateController.provider);
.read(GomuksListenerController.provider.notifier)
.deviceId();
final keyProvider = PushKeyController.provider(instance); final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
final key = await ref.read(keyProvider.future); final key = await ref.read(keyProvider.future);
if (key != null) { if (key != null) {
@ -245,7 +220,7 @@ class UnifiedPushController extends AsyncNotifier<String?> {
); );
} }
await UnifiedPush.unregister(instance); await UnifiedPush.unregister(instance ?? clientState!.deviceId!);
state = .data(null); state = .data(null);
} }

View file

@ -1,10 +1,8 @@
import "dart:async";
import "dart:io"; import "dart:io";
import "dart:isolate";
import "dart:ui" as ui; import "dart:ui" as ui;
import "package:avif_native/avif_native.dart";
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:flutter_avif/flutter_avif.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
@ -26,9 +24,13 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
final bytes = await File(file.path).readAsBytes(); final bytes = await File(file.path).readAsBytes();
// Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves // Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves
if (String.fromCharCodes(bytes.skip(4).take(8)) final avifType = isAvifFile(bytes.sublist(0, 16.clamp(0, bytes.length)));
case "ftypavif" || "ftypavis") { if (avifType != .unknown) {
return AvifCodec(await Isolate.run(() => decodeAvif(bytes))); final codec = avifType == .avif
? SingleFrameAvifCodec(bytes: bytes)
: MultiFrameAvifCodec(key: hashCode, avifBytes: bytes);
await codec.ready();
return AvifCodecAdapter(codec);
} }
return decode(await .fromUint8List(bytes)); return decode(await .fromUint8List(bytes));
@ -42,33 +44,21 @@ class MxcImage(final WidgetRef ref, final DownloadMediaRequest request)
int get hashCode => request.hashCode; int get hashCode => request.hashCode;
} }
class AvifCodec(final AvifImageData data) implements ui.Codec { class AvifCodecAdapter(final AvifCodec codec) implements ui.Codec {
var _frameIndex = 0; @override
int get frameCount => codec.frameCount;
@override @override
int get frameCount => data.frames.length; int get repetitionCount => frameCount > 1 ? -1 : 0;
// libavif uses -2 for unknown, which we treat as infinite like browsers do
@override
int get repetitionCount =>
data.repetitionCount < 0 ? -1 : data.repetitionCount;
@override @override
Future<ui.FrameInfo> getNextFrame() { Future<ui.FrameInfo> getNextFrame() async {
final frame = data.frames[_frameIndex++ % frameCount]; final frame = await codec.getNextFrame();
final completer = Completer<ui.FrameInfo>(); return AvifFrameInfo(frame.duration, frame.image);
ui.decodeImageFromPixels(
frame.rgba,
frame.width,
frame.height,
.rgba8888,
(image) => completer.complete(AvifFrameInfo(frame.duration, image)),
);
return completer.future;
} }
@override @override
void dispose() {} void dispose() => codec.dispose();
} }
class AvifFrameInfo( class AvifFrameInfo(

View file

@ -1,44 +0,0 @@
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<String, Object?> 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<String, Object?> json) =>
_$NotificationUserFromJson(json);
}

View file

@ -53,12 +53,6 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
ref, ref,
.new(mxc: Uri.parse(reaction)), .new(mxc: Uri.parse(reaction)),
), ),
errorBuilder: (context, _, _) => Icon(
Icons.broken_image,
color: Theme.of(context)
.colorScheme
.error,
),
) )
: Text(reaction, overflow: .ellipsis), : Text(reaction, overflow: .ellipsis),
), ),

View file

@ -5,7 +5,6 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/main.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/recovery_key_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
@ -17,12 +16,11 @@ class const ResetEncryptionSheet({
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final verificationState = ref.watch( final isReset = ref.watch(
ClientStateController.provider.select( ClientStateController.provider.select(
(state) => state?.verificationState, (state) => state?.verificationState.hasCrossSigning ?? false,
), ),
); );
final isReset = verificationState?.hasCrossSigning ?? false;
final usePassphrase = useState(false); final usePassphrase = useState(false);
final passphraseController = useTextEditingController(); final passphraseController = useTextEditingController();
@ -97,13 +95,6 @@ class const ResetEncryptionSheet({
), ),
], ],
children: [ 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( Text(
isReset 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." ? "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."

View file

@ -4,35 +4,53 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/settings/radio_sheet.dart"; import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({ final class const DialogListTile<T>({
required final T? value, required final T? initialValue,
required final Icon icon, required final Icon icon,
required final String title, required final String title,
required final List<T> options, required final List<T> options,
required final void Function(T value)? onChanged, required final void Function(T value)? onChanged,
required final String Function(T option) getName, required final String Function(T option) getName,
final Widget? subtitle, final Widget? subtitle,
final bool required = true,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => ListTile( Widget build(BuildContext context, WidgetRef ref) => FormField(
enabled: onChanged != null, validator: (value) =>
onTap: () => context.showAdaptiveSheet( value == null && required == true ? "This field is required." : null,
(context) => RadioSheet<T>( initialValue: initialValue,
title: title, builder: (field) => InputDecorator(
getName: getName, decoration: InputDecoration(
onChanged: onChanged, errorText: field.errorText,
value: value, contentPadding: EdgeInsets.zero,
options: options, enabledBorder: InputBorder.none,
), ),
), child: ListTile(
title: Text(title), enabled: onChanged != null,
subtitle: subtitle, onTap: () => context.showAdaptiveSheet(
leading: icon, (context) => RadioSheet<T>(
trailing: Chip( title: title,
label: Text( getName: getName,
value == null ? "None" : getName(value as T), onChanged: onChanged == null
overflow: TextOverflow.ellipsis, ? null
style: onChanged == null ? .new(color: Colors.grey) : 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,
),
),
), ),
), ),
); );

View file

@ -9,6 +9,7 @@
#include <app_links_linux/app_links_plugin_linux.h> #include <app_links_linux/app_links_plugin_linux.h>
#include <dynamic_color/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_plugin.h> #include <pasteboard/pasteboard_plugin.h>
@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
app_links_linux app_links_linux
dynamic_color dynamic_color
file_selector_linux file_selector_linux
flutter_avif_linux
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
pasteboard pasteboard

View file

@ -9,6 +9,7 @@ let
]; ];
cmakeVersions = [ "3.22.1" ]; cmakeVersions = [ "3.22.1" ];
platformVersions = [ platformVersions = [
"31"
"34" "34"
"36" "36"
]; ];
@ -47,7 +48,17 @@ pkgs.mkShell {
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2";
} }
// lib.optionalAttrs pkgs.stdenv.isLinux { // lib.optionalAttrs pkgs.stdenv.isLinux (
LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; 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 ])
];
}
);
} }

View file

@ -1,49 +0,0 @@
# 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
'';
}

View file

@ -34,8 +34,6 @@ flutter.buildFlutterApplication {
autoPubspecLock = src + "/pubspec.lock"; autoPubspecLock = src + "/pubspec.lock";
customSourceBuilders.avif_native = callPackage ./avif_native.nix { };
gitHashes = { gitHashes = {
linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4=";
fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";

View file

@ -17,7 +17,7 @@ buildGoModule (
src = "${src}/gomuks"; src = "${src}/gomuks";
vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; vendorHash = "sha256-+0iFK2ribhiNskXgPz7vslncuOgPMB/ZmFSfDOcz6ww=";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild

View file

@ -8,6 +8,7 @@ import Foundation
import app_links import app_links
import dynamic_color import dynamic_color
import file_selector_macos import file_selector_macos
import flutter_avif_macos
import flutter_local_notifications import flutter_local_notifications
import media_kit_libs_macos_video import media_kit_libs_macos_video
import media_kit_video import media_kit_video
@ -15,6 +16,7 @@ import package_info_plus
import pasteboard import pasteboard
import screen_retriever_macos import screen_retriever_macos
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import wakelock_plus import wakelock_plus
import webcrypto import webcrypto
@ -24,6 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
@ -31,6 +34,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin"))

View file

@ -105,14 +105,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -321,6 +313,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
exif:
dependency: transitive
description:
name: exif
sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172
url: "https://pub.dev"
source: hosted
version: "3.3.0"
fab_m3e: fab_m3e:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -455,6 +455,70 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_blurhash:
dependency: "direct main" dependency: "direct main"
description: description:
@ -463,6 +527,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.1" 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: flutter_file_dialog:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1193,6 +1265,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.6" version: "5.0.6"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -1406,6 +1486,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:

View file

@ -109,7 +109,7 @@ dependencies:
pasteboard: 0.5.0 pasteboard: 0.5.0
flutter_file_dialog: 3.3.3 flutter_file_dialog: 3.3.3
mime: 2.1.0 mime: 2.1.0
avif_native: 0.1.1 flutter_avif: 3.1.0
dev_dependencies: dev_dependencies:
build_runner: 2.16.2 build_runner: 2.16.2
flutter_lints: 6.0.0 flutter_lints: 6.0.0
@ -133,8 +133,3 @@ flutter_launcher_icons:
macos: macos:
generate: true generate: true
image_path: assets/mobile.png image_path: assets/mobile.png
hooks:
user_defines:
avif_native:
features: decode

View file

@ -9,6 +9,7 @@
#include <app_links/app_links_plugin_c_api.h> #include <app_links/app_links_plugin_c_api.h>
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_avif_windows/flutter_avif_windows_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <pasteboard/pasteboard_plugin.h> #include <pasteboard/pasteboard_plugin.h>
@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterAvifWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
dynamic_color dynamic_color
file_selector_windows file_selector_windows
flutter_avif_windows
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
pasteboard pasteboard