diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart index cb5db3cc3..f304bde09 100644 --- a/integration_test/app_test.dart +++ b/integration_test/app_test.dart @@ -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); }); }); diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index cedef2c13..151634e5d 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -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'; diff --git a/lib/main.dart b/lib/main.dart index 62355e518..dcad06af8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,7 @@ import 'widgets/fluffy_chat_app.dart'; ReceivePort? mainIsolateReceivePort; -void main() async { +void main(List 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( diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart index 2713073b4..0af56bb2a 100644 --- a/lib/utils/background_push.dart +++ b/lib/utils/background_push.dart @@ -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? _initializing; - Future _init() async { + Future _init(bool isBackground) async { //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 { // flutterLocalNotificationsPlugin: _flutterLocalNotificationsPlugin, // ), //); - 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 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 _noFcmWarning() async { @@ -437,7 +457,7 @@ class BackgroundPush { } Future _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.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 ); } } diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 817727e94..088e16d8a 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -166,6 +166,7 @@ abstract class ClientManager { settings: const InitializationSettings( android: AndroidInitializationSettings('notifications_icon'), iOS: DarwinInitializationSettings(), + linux: LinuxInitializationSettings(defaultActionName: "Open chat"), ), ); diff --git a/lib/utils/platform_infos.dart b/lib/utils/platform_infos.dart index 0f225b733..5d08e3ab3 100644 --- a/lib/utils/platform_infos.dart +++ b/lib/utils/platform_infos.dart @@ -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; diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index 4b10623e3..da92c0468 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -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 _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 _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( + 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)); diff --git a/lib/widgets/local_notifications_extension.dart b/lib/widgets/local_notifications_extension.dart index ee5708a5e..bae356d7e 100644 --- a/lib/widgets/local_notifications_extension.dart +++ b/lib/widgets/local_notifications_extension.dart @@ -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; } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 60fd73b77..06c3473fa 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -305,9 +305,10 @@ class MatrixState extends State 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: diff --git a/linux/my_application.cc b/linux/my_application.cc index 986be4ea3..6e171a737 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -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. diff --git a/pubspec.lock b/pubspec.lock index a2e3dc834..6f2d2c779 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index a79be8149..080c181a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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