feat: implement UnifiedPush on Linux

This commit is contained in:
Matias 2026-02-03 00:53:53 +01:00
parent f0aa15843b
commit 44c627f607
No known key found for this signature in database
GPG key ID: ED35A6AC65A06B69
12 changed files with 326 additions and 58 deletions

View file

@ -17,11 +17,170 @@ import 'users.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Integration Test', () {
setUpAll(() {
// this random dialog popping up is super hard to cover in tests
SharedPreferences.setMockInitialValues({
'chat.fluffy.show_no_google': false,
group(
'Integration Test',
() {
setUpAll(
() async {
// this random dialog popping up is super hard to cover in tests
SharedPreferences.setMockInitialValues({
'chat.fluffy.show_no_google': false,
});
},
);
testWidgets(
'Start app, login and logout',
(WidgetTester tester) async {
app.main([]);
await tester.ensureAppStartedHomescreen();
await tester.ensureLoggedOut();
},
);
testWidgets(
'Login again',
(WidgetTester tester) async {
app.main([]);
await tester.ensureAppStartedHomescreen();
},
);
testWidgets(
'Start chat and send message',
(WidgetTester tester) async {
app.main([]);
await tester.ensureAppStartedHomescreen();
await tester.waitFor(find.byType(TextField));
await tester.enterText(find.byType(TextField), Users.user2.name);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Chats').first,
500,
scrollable: find
.descendant(
of: find.byType(ChatListViewBody),
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle();
await tester.tap(find.text('Chats'));
await tester.pumpAndSettle();
await tester.waitFor(find.byType(SearchTitle));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text(Users.user2.name).first,
500,
scrollable: find
.descendant(
of: find.byType(ChatListViewBody),
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle();
await tester.tap(find.text(Users.user2.name).first);
try {
await tester.waitFor(
find.byType(ChatView),
timeout: const Duration(seconds: 5),
);
} catch (_) {
// in case the homeserver sends the username as search result
if (find.byIcon(Icons.send_outlined).evaluate().isNotEmpty) {
await tester.tap(find.byIcon(Icons.send_outlined));
await tester.pumpAndSettle();
}
}
await tester.waitFor(find.byType(ChatView));
await tester.enterText(find.byType(TextField).last, 'Test');
await tester.pumpAndSettle();
try {
await tester.waitFor(find.byIcon(Icons.send_outlined));
await tester.tap(find.byIcon(Icons.send_outlined));
} catch (_) {
await tester.testTextInput.receiveAction(TextInputAction.done);
}
await tester.pumpAndSettle();
await tester.waitFor(find.text('Test'));
await tester.pumpAndSettle();
},
);
testWidgets('Spaces', (tester) async {
app.main([]);
await tester.ensureAppStartedHomescreen();
await tester.waitFor(find.byTooltip('Show menu'));
await tester.tap(find.byTooltip('Show menu'));
await tester.pumpAndSettle();
await tester.waitFor(find.byIcon(Icons.workspaces_outlined));
await tester.tap(find.byIcon(Icons.workspaces_outlined));
await tester.pumpAndSettle();
await tester.waitFor(find.byType(TextField));
await tester.enterText(find.byType(TextField).last, 'Test Space');
await tester.pumpAndSettle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.waitFor(find.text('Invite contact'));
await tester.tap(find.text('Invite contact'));
await tester.pumpAndSettle();
await tester.waitFor(
find.descendant(
of: find.byType(InvitationSelectionView),
matching: find.byType(TextField),
),
);
await tester.enterText(
find.descendant(
of: find.byType(InvitationSelectionView),
matching: find.byType(TextField),
),
Users.user2.name,
);
await Future.delayed(const Duration(milliseconds: 250));
await tester.testTextInput.receiveAction(TextInputAction.done);
await Future.delayed(const Duration(milliseconds: 1000));
await tester.pumpAndSettle();
await tester.tap(
find
.descendant(
of: find.descendant(
of: find.byType(InvitationSelectionView),
matching: find.byType(ListTile),
),
matching: find.text(Users.user2.name),
)
.last,
);
await tester.pumpAndSettle();
await tester.waitFor(find.maybeUppercaseText('Yes'));
await tester.tap(find.maybeUppercaseText('Yes'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
await tester.waitFor(find.text('Load 2 more participants'));
await tester.tap(find.text('Load 2 more participants'));
await tester.pumpAndSettle();
expect(find.text(Users.user2.name), findsOneWidget);
});
});

View file

@ -16,7 +16,7 @@ abstract class AppConfig {
static const String deepLinkPrefix = 'im.fluffychat://chat/';
static const String schemePrefix = 'matrix:';
static const String pushNotificationsChannelId = 'fluffychat_push';
static const String pushNotificationsAppId = 'chat.fluffy.fluffychat';
static const String pushNotificationsAppId = 'chat.fluffy.fluffychat'; // except for Linux!
static const double borderRadius = 18.0;
static const double spaceBorderRadius = 11.0;
static const double columnWidth = 360.0;
@ -32,6 +32,7 @@ abstract class AppConfig {
'https://fluffy.chat/faq/#how_do_i_get_stickers';
static const String appId = 'im.fluffychat.FluffyChat';
static const String appOpenUrlScheme = 'chat.fluffy';
static const String appIdFlatpak = 'im.fluffychat.Fluffychat';
static const String sourceCodeUrl =
'https://github.com/krille-chan/fluffychat';

View file

@ -21,7 +21,7 @@ import 'widgets/fluffy_chat_app.dart';
ReceivePort? mainIsolateReceivePort;
void main() async {
void main(List<String> args) async {
if (PlatformInfos.isAndroid) {
final port = mainIsolateReceivePort = ReceivePort();
IsolateNameServer.removePortNameMapping(AppConfig.mainIsolatePortName);
@ -55,18 +55,29 @@ void main() async {
// If the app starts in detached mode, we assume that it is in
// background fetch mode for processing push notifications. This is
// currently only supported on Android.
if (PlatformInfos.isAndroid &&
AppLifecycleState.detached == WidgetsBinding.instance.lifecycleState) {
// currently only supported on Android and Linux.
final backgroundMode = (() {
if (PlatformInfos.isAndroid) {
return WidgetsBinding.instance.lifecycleState ==
AppLifecycleState.detached;
}
if (PlatformInfos.isLinux) {
return args.contains('--unifiedpush-bg');
}
return false;
})();
if (backgroundMode) {
// Do not send online presences when app is in background fetch mode.
for (final client in clients) {
client.backgroundSync = false;
client.syncPresence = PresenceType.offline;
}
// In the background fetch mode we do not want to waste ressources with
// In the background fetch mode we do not want to waste resources with
// starting the Flutter engine but process incoming push notifications.
BackgroundPush.clientOnly(clients.first);
BackgroundPush.clientOnly(clients.first, backgroundMode);
// To start the flutter engine afterwards we add an custom observer.
WidgetsBinding.instance.addObserver(AppStarter(clients, store));
Logs().i(

View file

@ -31,6 +31,8 @@ import 'package:flutter_new_badger/flutter_new_badger.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:unifiedpush/unifiedpush.dart';
import 'package:unifiedpush_platform_interface/unifiedpush_platform_interface.dart';
import 'package:unifiedpush_storage_shared_preferences/storage.dart';
import 'package:unifiedpush_ui/unifiedpush_ui.dart';
import 'package:fluffychat/l10n/l10n.dart';
@ -71,8 +73,9 @@ class BackgroundPush {
DateTime? lastReceivedPush;
bool upAction = false;
Future<void>? _initializing;
Future<void> _init() async {
Future<void> _init(bool isBackground) async {
//<GOOGLE_SERVICES>firebaseEnabled = true;
try {
mainIsolateReceivePort?.listen((message) async {
@ -111,6 +114,7 @@ class BackgroundPush {
settings: const InitializationSettings(
android: AndroidInitializationSettings('notifications_icon'),
iOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: "Open chat"),
),
onDidReceiveNotificationResponse: (response) => notificationTap(
response,
@ -132,12 +136,21 @@ class BackgroundPush {
//<GOOGLE_SERVICES> flutterLocalNotificationsPlugin: _flutterLocalNotificationsPlugin,
//<GOOGLE_SERVICES> ),
//<GOOGLE_SERVICES>);
if (Platform.isAndroid) {
if (PlatformInfos.canUseUnifiedPush) {
final linuxOptions = LinuxOptions(
// NOTE: we don't use pushNotificationsAppId because it's different from
// the actual app id, (im.fluffychat.Fluffychat), and appId has the wrong casing
dbusName: AppConfig.appIdFlatpak,
storage: UnifiedPushStorageSharedPreferences(),
background: isBackground,
);
await UnifiedPush.initialize(
onNewEndpoint: _newUpEndpoint,
onRegistrationFailed: (_, i) => _upUnregistered(i),
onUnregistered: _upUnregistered,
onMessage: _onUpMessage,
linuxOptions: linuxOptions,
);
}
} catch (e, s) {
@ -145,19 +158,20 @@ class BackgroundPush {
}
}
BackgroundPush._(this.client) {
_init();
BackgroundPush._(this.client, bool isBackground) {
_initializing ??= _init(isBackground);
}
factory BackgroundPush.clientOnly(Client client) {
return _instance ??= BackgroundPush._(client);
factory BackgroundPush.clientOnly(Client client, bool isBackground) {
return _instance ??= BackgroundPush._(client, isBackground);
}
factory BackgroundPush(
MatrixState matrix, {
isBackground = false,
final void Function(String errorMsg, {Uri? link})? onFcmError,
}) {
final instance = BackgroundPush.clientOnly(matrix.client);
final instance = BackgroundPush.clientOnly(matrix.client, isBackground);
instance.matrix = matrix;
// ignore: prefer_initializing_formals
instance.onFcmError = onFcmError;
@ -208,7 +222,7 @@ class BackgroundPush {
[];
var setNewPusher = false;
// Just the plain app id, we add the .data_message suffix later
var appId = AppConfig.pushNotificationsAppId;
var appId = PlatformInfos.isLinux ? AppConfig.appIdFlatpak : AppConfig.pushNotificationsAppId;
// we need the deviceAppId to remove potential legacy UP pusher
var deviceAppId = '$appId.${client.deviceID}';
// appId may only be up to 64 chars as per spec
@ -292,7 +306,7 @@ class BackgroundPush {
Future<void> setupPush() async {
Logs().d('SetupPush');
if (client.onLoginStateChanged.value != LoginState.loggedIn ||
!PlatformInfos.isMobile ||
!(PlatformInfos.isMobile || PlatformInfos.canUseUnifiedPush) ||
matrix == null) {
return;
}
@ -301,33 +315,39 @@ class BackgroundPush {
if (upAction) {
return;
}
await _initializing;
if (!PlatformInfos.isIOS &&
(await UnifiedPush.getDistributors()).isNotEmpty) {
Logs().d((await UnifiedPush.getDistributors()).join(','));
await setupUp();
} else {
await setupFirebase();
}
// ignore: unawaited_futures
_flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails().then((
details,
) {
if (details == null ||
!details.didNotificationLaunchApp ||
_wentToRoomOnStartup) {
return;
}
_wentToRoomOnStartup = true;
final response = details.notificationResponse;
if (response != null) {
notificationTap(
response,
client: client,
router: FluffyChatApp.router,
l10n: l10n,
);
}
});
// NOTE: unsupported on Linux (oof)
if (!PlatformInfos.isLinux) {
// ignore: unawaited_futures
_flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails().then((
details,
) {
if (details == null ||
!details.didNotificationLaunchApp ||
_wentToRoomOnStartup) {
return;
}
_wentToRoomOnStartup = true;
final response = details.notificationResponse;
if (response != null) {
notificationTap(
response,
client: client,
router: FluffyChatApp.router,
l10n: l10n,
);
}
});
}
}
Future<void> _noFcmWarning() async {
@ -437,7 +457,7 @@ class BackgroundPush {
}
Future<void> _onUpMessage(PushMessage pushMessage, String i) async {
Logs().wtf('Push Notification from UP received', pushMessage);
Logs().i('Push Notification from UP received', pushMessage);
final message = pushMessage.content;
upAction = true;
final data = Map<String, dynamic>.from(
@ -451,8 +471,8 @@ class BackgroundPush {
l10n: l10n,
activeRoomId: matrix?.activeRoomId,
flutterLocalNotificationsPlugin: _flutterLocalNotificationsPlugin,
useNotificationActions:
false, // Buggy with UP: https://codeberg.org/UnifiedPush/flutter-connector/issues/34
useNotificationActions: !PlatformInfos
.isAndroid, // Buggy with UP: https://codeberg.org/UnifiedPush/flutter-connector/issues/34
);
}
}

View file

@ -166,6 +166,7 @@ abstract class ClientManager {
settings: const InitializationSettings(
android: AndroidInitializationSettings('notifications_icon'),
iOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: "Open chat"),
),
);

View file

@ -29,6 +29,7 @@ abstract class PlatformInfos {
static bool get isDesktop => isLinux || isWindows || isMacOS;
static bool get usesTouchscreen => !isMobile;
static bool get canUseUnifiedPush => isAndroid || isLinux;
static bool get supportsVideoPlayer =>
!PlatformInfos.isWindows && !PlatformInfos.isLinux;

View file

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_shortcuts_new/flutter_shortcuts_new.dart';
import 'package:image/image.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
@ -252,6 +253,29 @@ Future<void> _tryPushHelper(
>()
?.createNotificationChannel(roomsChannel);
LinuxNotificationIcon? linuxIcon;
if (PlatformInfos.isLinux) {
if (roomAvatarFile != null) {
final image = decodeImage(roomAvatarFile);
if (image != null) {
final realData = image.getBytes(order: ChannelOrder.rgba);
linuxIcon = ByteDataLinuxIcon(
LinuxRawIconData(
data: realData,
width: image.width,
height: image.height,
rowStride: image.rowStride,
bitsPerSample: image.bitsPerChannel * image.numChannels,
channels: image.numChannels,
hasAlpha: true,
),
);
} else {
linuxIcon = ThemeLinuxIcon("fluffychat");
}
}
}
final androidPlatformChannelSpecifics = AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
l10n.incomingMessages,
@ -305,9 +329,32 @@ Future<void> _tryPushHelper(
],
);
const iOSPlatformChannelSpecifics = DarwinNotificationDetails();
final linuxPlatformChannelSpecifics = LinuxNotificationDetails(
icon: linuxIcon,
sound: ThemeLinuxSound("message-new-instant"),
category: LinuxNotificationCategory.imReceived,
urgency: LinuxNotificationUrgency.normal,
resident: false, // remove upon interaction
suppressSound: false, // we don't play a sound ourselves
actions: event.type == EventTypes.RoomMember || !useNotificationActions
? []
: <LinuxNotificationAction>[
LinuxNotificationAction(
key: FluffyChatNotificationActions.reply.name,
label: l10n.reply,
),
LinuxNotificationAction(
key: FluffyChatNotificationActions.markAsRead.name,
label: l10n.markAsRead,
),
],
// TODO: can we do KNotificationReplyAction?
// Though, would be better if this were a standard.
);
final platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics,
linux: linuxPlatformChannelSpecifics,
);
final title = event.room.getLocalizedDisplayname(MatrixLocals(l10n));

View file

@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
import 'package:desktop_notifications/desktop_notifications.dart';
import 'package:image/image.dart';
import 'package:matrix/matrix.dart';
import 'package:unifiedpush/unifiedpush.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/setting_keys.dart';
@ -82,9 +83,17 @@ extension LocalNotificationsExtension on MatrixState {
icon: thumbnailUri?.toString(),
tag: event.room.id,
);
} else if (Platform.isLinux) {
} else if (Platform.isLinux &&
(await UnifiedPush.getDistributor() != null)) {
final avatarUrl = event.room.avatar;
final hints = [NotificationHint.soundName('message-new-instant')];
final hints = [
NotificationHint.soundName('message-new-instant'),
NotificationHint.category(
event.type == EventTypes.Message
? NotificationCategory.imReceived()
: NotificationCategory.im(),
),
];
if (avatarUrl != null) {
const size = notificationAvatarDimension;
@ -119,16 +128,18 @@ extension LocalNotificationsExtension on MatrixState {
replacesId: linuxNotificationIds[roomId] ?? 0,
appName: AppSettings.applicationName.value,
appIcon: 'fluffychat',
actions: [
NotificationAction(
DesktopNotificationActions.openChat.name,
L10n.of(context).openChat,
),
NotificationAction(
DesktopNotificationActions.seen.name,
L10n.of(context).markAsRead,
),
],
actions: event.type == EventTypes.RoomMember
? []
: [
NotificationAction(
DesktopNotificationActions.openChat.name,
L10n.of(context).openChat,
),
NotificationAction(
DesktopNotificationActions.seen.name,
L10n.of(context).markAsRead,
),
],
hints: hints,
);
notification.action.then((actionStr) {
@ -149,6 +160,7 @@ extension LocalNotificationsExtension on MatrixState {
case DesktopNotificationActions.openChat:
setActiveClient(event.room.client);
// TODO: raise window via token
FluffyChatApp.router.go('/rooms/${event.room.id}');
break;
}

View file

@ -305,9 +305,10 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
_registerSubs(c.clientName);
}
if (PlatformInfos.isMobile) {
if (PlatformInfos.isMobile || PlatformInfos.isLinux) {
backgroundPush = BackgroundPush(
this,
isBackground: false,
onFcmError: (errorMsg, {Uri? link}) async {
final result = await showOkCancelAlertDialog(
context:

View file

@ -73,6 +73,11 @@ static void my_application_activate(GApplication* application) {
gtk_widget_show(GTK_WIDGET(window));
gtk_widget_show(GTK_WIDGET(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
if (g_getenv("FLUTTER_HEADLESS")) {
printf("Hiding window...\n");
gtk_widget_hide(GTK_WIDGET(window));
}
}
// Implements GApplication::local_command_line.

View file

@ -1962,7 +1962,7 @@ packages:
source: hosted
version: "1.0.0"
unifiedpush_platform_interface:
dependency: transitive
dependency: "direct main"
description:
name: unifiedpush_platform_interface
sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880"
@ -1977,6 +1977,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
unifiedpush_storage_shared_preferences:
dependency: "direct main"
description:
name: unifiedpush_storage_shared_preferences
sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4
url: "https://pub.dev"
source: hosted
version: "1.0.0"
unifiedpush_ui:
dependency: "direct main"
description:

View file

@ -77,6 +77,8 @@ dependencies:
sqlcipher_flutter_libs: ^0.6.8
swipe_to_action: ^0.3.0
unifiedpush: ^6.2.0
unifiedpush_platform_interface: ^4.0.0
unifiedpush_storage_shared_preferences: ^1.0.0
unifiedpush_ui: ^0.2.0
universal_html: ^2.3.0
url_launcher: ^6.3.2