diff --git a/lib/model/binding.dart b/lib/model/binding.dart index d387b23cf7..12f8b611ff 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -7,6 +7,7 @@ import 'package:firebase_messaging/firebase_messaging.dart' as firebase_messagin import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart' as image_picker; import 'package:package_info_plus/package_info_plus.dart' as package_info_plus; +import 'package:sodium_libs/sodium_libs.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; @@ -166,6 +167,12 @@ abstract class ZulipBinding { /// or null if that hasn't resolved yet. PackageInfo? get syncPackageInfo; + /// Get the singleton for `package:sodium_libs` aka libsodium, + /// used for cryptography. + /// + /// This wraps [SodiumInit.init]. + Future sodiumInit(); + /// Initialize Firebase, to use for notifications. /// /// This wraps [firebase_core.Firebase.initializeApp]. @@ -477,6 +484,9 @@ class LiveZulipBinding extends ZulipBinding { return _syncPackageInfo; } + @override + Future sodiumInit() => SodiumInit.init(); + @override Future firebaseInitializeApp({ required firebase_core.FirebaseOptions options}) { diff --git a/lib/model/push_key.dart b/lib/model/push_key.dart index 0fa9d75786..bc6d5e8b9a 100644 --- a/lib/model/push_key.dart +++ b/lib/model/push_key.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'binding.dart'; import 'database.dart'; import 'store.dart'; @@ -157,5 +158,37 @@ class PushKeyStore { /// The tag byte for a libsodium secretbox-based `pushKey` value. /// /// See API doc: https://zulip.com/api/register-push-device#parameter-push_key + @visibleForTesting static const pushKeyTagSecretbox = 0x31; + + @visibleForTesting + static Uint8List secretboxKeyFromPushKey(Uint8List pushKey) { + const keyLengthBytes = 32; + if (pushKey.length != 1 + keyLengthBytes) { + throw ArgumentError("Bad push key: length ${pushKey.length}"); + } + if (pushKey[0] != pushKeyTagSecretbox) { + throw ArgumentError("Bad push key: tag 0x${pushKey[0].toRadixString(16)}"); + } + return Uint8List.sublistView(pushKey, 1); + } + + static Future decryptNotification(Uint8List pushKey, Uint8List cryptotext) async { + final keyBytes = secretboxKeyFromPushKey(pushKey); + + // TODO(#1764) document how the nonce and cryptotext are packed; https://chat.zulip.org/#narrow/channel/378-api-design/topic/E2EE.20-.20cryptography/near/2352462 + const nonceLength = 24; + final nonce = Uint8List.sublistView(cryptotext, 0, nonceLength); + final actualCryptotext = Uint8List.sublistView(cryptotext, nonceLength); + + // The Sodium docs say to call `WidgetsFlutterBinding.ensureInitialized()` + // before `SodiumInit.init()` (and so this `sodiumInit()`). + // But empirically things seem to work fine without, including + // when the app was in the background or not running. + final sodium = await ZulipBinding.instance.sodiumInit(); + + final key = sodium.secureCopy(keyBytes); + return sodium.crypto.secretBox.openEasy(key: key, + cipherText: actualCryptotext, nonce: nonce); + } } diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 1625fbbb5e..5b6cd71b00 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -12,6 +11,7 @@ import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; +import '../model/store.dart'; import '../widgets/color.dart'; import '../widgets/theme.dart'; import 'open.dart'; @@ -216,33 +216,23 @@ class NotificationDisplayManager { await NotificationChannelManager.ensureChannel(); } - static void onFcmMessage(FcmMessage data, Map dataJson) { + static void onFcmMessage(FcmMessageWithIdentity data, Account account) async { assert(defaultTargetPlatform == TargetPlatform.android); switch (data) { - case MessageFcmMessage(): _onMessageFcmMessage(data, dataJson); - case RemoveFcmMessage(): _onRemoveFcmMessage(data); - case UnexpectedFcmMessage(): break; // TODO(log) + case MessageFcmMessage(): await _onMessageFcmMessage(data, account); + case RemoveFcmMessage(): await _onRemoveFcmMessage(data); } } - static Future _onMessageFcmMessage(MessageFcmMessage data, Map dataJson) async { + static Future _onMessageFcmMessage(MessageFcmMessage data, Account account) async { assert(debugLog('notif message content: ${data.content}')); + assert(account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; final groupKey = _groupKey(data.realmUrl, data.userId); final conversationKey = _conversationKey(data, groupKey); - final globalStore = await ZulipBinding.instance.getGlobalStore(); - final account = globalStore.accounts.firstWhereOrNull((account) => - account.realmUrl.origin == data.realmUrl.origin && account.userId == data.userId); - - // Skip showing notifications for a logged-out account. This can occur if - // the unregisterToken request failed previously. It would be annoying - // to the user if notifications keep showing up after they've logged out. - // (Also alarming: it suggests the logout didn't fully work.) - if (account == null) { - return; - } - final oldMessagingStyle = await _androidHost .getActiveNotificationMessagingStyleByTag(conversationKey); @@ -365,7 +355,7 @@ class NotificationDisplayManager { ); } - static void _onRemoveFcmMessage(RemoveFcmMessage data) async { + static Future _onRemoveFcmMessage(RemoveFcmMessage data) async { // We have an FCM message telling us that some Zulip messages were read // and should no longer appear as notifications. We'll remove their // conversations' notifications, if appropriate, and then the whole diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 39849ef7d5..1706f39ffd 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; @@ -7,6 +8,7 @@ import '../api/route/notifications.dart'; import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; +import '../model/push_key.dart'; import 'display.dart'; import 'open.dart'; @@ -203,8 +205,64 @@ class NotificationService { NotificationDisplayManager.init(); // TODO call this just once per isolate } - static void _onRemoteMessage(FirebaseRemoteMessage message) { - final data = FcmMessage.fromJson(message.data); - NotificationDisplayManager.onFcmMessage(data, message.data); + static void _onRemoteMessage(FirebaseRemoteMessage message) async { + final origData = message.data; + + EncryptedFcmMessage? parsed; + try { + parsed = EncryptedFcmMessage.fromJson(origData); + } catch (_) { + // Presumably a non-E2EE notification. // TODO(server-12) + await _onPlaintextRemoteMessage(origData); + return; + } + + final globalStore = await ZulipBinding.instance.getGlobalStore(); + final pushKey = globalStore.pushKeys.getPushKeyById(parsed.pushKeyId); + if (pushKey == null) { + // Not a key we have; nothing we can do with this notification-message. + // This can happen if it's addressed to an account that's been logged out. + // TODO(#1764) try to unregister on logout (though this will still sometimes happen) + return; + } + final account = globalStore.getAccount(pushKey.accountId)!; + + final plaintext = await PushKeyStore.decryptNotification( + pushKey.pushKey, parsed.encryptedData); + final rawData = jsonUtf8Decoder.convert(plaintext) as Map; + final data = FcmMessage.fromJson(rawData); + switch (data) { + case FcmMessageWithIdentity(): break; + case UnexpectedFcmMessage(): return; // TODO(log) + } + + if (!(account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId)) { + throw Exception("bad notif payload: realm/userId fails to match push key"); + } + + NotificationDisplayManager.onFcmMessage(data, account); + } + + static Future _onPlaintextRemoteMessage(Map rawData) async { + final data = FcmMessage.fromJson(rawData); + switch (data) { + case FcmMessageWithIdentity(): break; + case UnexpectedFcmMessage(): return; // TODO(log) + } + + final globalStore = await ZulipBinding.instance.getGlobalStore(); + final account = globalStore.accounts.firstWhereOrNull((account) => + account.realmUrl.origin == data.realmUrl.origin && account.userId == data.userId); + + // Skip showing notifications for a logged-out account. This can occur if + // the unregisterToken request failed previously. It would be annoying + // to the user if notifications keep showing up after they've logged out. + // (Also alarming: it suggests the logout didn't fully work.) + if (account == null) { + return; + } + + NotificationDisplayManager.onFcmMessage(data, account); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 9f99dda793..47f1865fff 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) sodium_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin"); + sodium_libs_plugin_register_with_registrar(sodium_libs_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 3ff88a36d4..9d412be166 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + sodium_libs sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 045e3bf217..8ed52756b7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import firebase_messaging import package_info_plus import patrol import share_plus +import sodium_libs import sqlite3_flutter_libs import url_launcher_macos import video_player_avfoundation @@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PatrolPlugin.register(with: registry.registrar(forPlugin: "PatrolPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index a2c14e1318..cbfafd7b64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -476,6 +476,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -1027,6 +1035,22 @@ packages: description: flutter source: sdk version: "0.0.0" + sodium: + dependency: transitive + description: + name: sodium + sha256: "515b86c186f4caca49051caf858d878ca7cc4ff4542411e9febb50654eac8a62" + url: "https://pub.dev" + source: hosted + version: "3.4.6" + sodium_libs: + dependency: "direct main" + description: + name: sodium_libs + sha256: f3f9c516b4183226b7a08ca43a765ebc9e02cfd92e46e8a6cc490f98ffe73052 + url: "https://pub.dev" + source: hosted + version: "3.4.6+4" source_gen: dependency: transitive description: @@ -1131,6 +1155,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51e68d6a58..f0d98f6fd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,15 +57,16 @@ dependencies: path_provider: ^2.0.13 share_plus: ^12.0.0 share_plus_platform_interface: ^6.0.0 + sodium_libs: ^3.4.6+4 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 + unorm_dart: ^0.3.1+1 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" video_player: ^2.10.0 wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin - unorm_dart: ^0.3.1+1 # Keep list sorted when adding dependencies; it helps prevent merge conflicts. dependency_overrides: diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart index c28bd76fec..ddcc23253e 100644 --- a/test/model/actions_test.dart +++ b/test/model/actions_test.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:checks/checks.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -156,10 +155,13 @@ void main() { NotificationService.debugBackgroundIsolateIsLive = false; await runWithHttpClient(NotificationService.instance.start); + await testBinding.globalStore.pushKeys.perAccount(eg.selfAccount.id).insertPushKey( + eg.pushKey(account: eg.selfAccount).toCompanion(false)); + // Create a notification to check that it's removed after logout final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); testBinding.firebaseMessaging.onMessage.add( - RemoteMessage(data: messageFcmMessage(message).toJson())); + await encodeFcmMessage(messageFcmMessage(message))); async.flushMicrotasks(); check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty(); diff --git a/test/model/binding.dart b/test/model/binding.dart index f0f0a56349..35cf0eacbb 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -1,10 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:sodium_libs/sodium_libs.dart' as sodium; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_intents.dart'; @@ -278,6 +282,9 @@ class TestZulipBinding extends ZulipBinding { @override PackageInfo? get syncPackageInfo => packageInfoResult; + @override + Future sodiumInit() async => FakeSodium(); + void _resetFirebase() { _firebaseInitialized = false; _firebaseMessaging = null; @@ -427,6 +434,78 @@ class TestZulipBinding extends ZulipBinding { Stream get androidIntentEvents => throw UnimplementedError(); } +class FakeSodium extends Fake implements sodium.Sodium { + @override + sodium.SecureKey secureCopy(Uint8List data) => FakeSodiumSecureKey(data); + + @override + sodium.Crypto get crypto => FakeSodiumCrypto(); +} + +class FakeSodiumSecureKey extends Fake implements sodium.SecureKey { + FakeSodiumSecureKey(this.bytes); + + final Uint8List bytes; + + @override + Uint8List extractBytes() => bytes; +} + +class FakeSodiumCrypto extends Fake implements sodium.Crypto { + @override + sodium.SecretBox get secretBox => FakeSodiumSecretBox(); +} + +class FakeSodiumSecretBox extends Fake implements sodium.SecretBox { + @override + Uint8List openEasy({ + required Uint8List cipherText, + required Uint8List nonce, + required sodium.SecureKey key, + }) { + int offset = 0; + Uint8List take(int length) => + Uint8List.sublistView(cipherText, offset, offset += length); + + void checkMatch(List piece, [int? length]) { + length ??= piece.length; + if (!take(length).equals(piece)) throw Exception('invalid ciphertext'); + } + + final messageLength = cipherText.length + - (_prefix.length + _infix.length + 32 + _suffix.length); + if (messageLength < 0) throw Exception('invalid ciphertext'); + + checkMatch(_prefix); + final result = take(messageLength); + checkMatch(_infix); + checkMatch(sha256.convert(key.extractBytes()).bytes, 32); + checkMatch(_suffix); + assert(offset == cipherText.length); + + return result; + } + + static final _prefix = utf8.encode('sekrit, don\'t peek: ['); + static final _infix = utf8.encode(']; signed, ['); + static final _suffix = utf8.encode(']'); + + @override + Uint8List easy({ // TODO include nonce too? (and check in open) + required Uint8List message, + required Uint8List nonce, + required sodium.SecureKey key, + }) { + return Uint8List.fromList([ + ..._prefix, + ...message, + ..._infix, + ...sha256.convert(key.extractBytes()).bytes, + ..._suffix, + ]); + } +} + class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { //|////////////////////////////// // Permissions. diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 943e8a800c..f724a8ff11 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:checks/checks.dart'; @@ -14,6 +16,7 @@ import 'package:zulip/host/android_notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/push_key.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; import 'package:zulip/notifications/open.dart'; @@ -28,6 +31,56 @@ import '../model/store_checks.dart'; import '../test_images.dart'; import '../api/notifications_test.dart'; +Future _encryptNotification(Uint8List pushKey, Uint8List plaintext) async { + // Compare [PushKeyStore.decryptNotification]. + final keyBytes = PushKeyStore.secretboxKeyFromPushKey(pushKey); + + final sodium = await testBinding.sodiumInit(); + final key = sodium.secureCopy(keyBytes); + + const nonceLength = 24; + final rand = Random.secure(); + final nonce = Uint8List(nonceLength) + ..setRange(0, nonceLength, Iterable.generate(nonceLength, (_) => + rand.nextInt(1 << 8))); + + final ciphertext = sodium.crypto.secretBox.easy( + key: key, message: plaintext, nonce: nonce); + return Uint8List.fromList([...nonce, ...ciphertext]); +} + +/// Encode an FCM message payload into the form Firebase would supply it in. +/// +/// The result is suitable for passing to a method like +/// `testBinding.firebaseMessaging.onMessage`. +/// +/// If [encrypted] is true, then produce an E2EE notification, +/// encrypted to the latest push key found on the account +/// that the payload is addressed to. +/// Otherwise, produce a legacy non-E2EE notification. +// TODO(server-12): cut the `encrypted: false` case +Future encodeFcmMessage(FcmMessageWithIdentity data, { + bool encrypted = true, +}) async { + final Map payload; + if (!encrypted) { + payload = data.toJson(); + } else { + final account = testBinding.globalStore.accounts.where((account) => + account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId).single; + final pushKey = testBinding.globalStore.pushKeys.perAccount(account.id) + .latestPushKey!; + final encrypted = await _encryptNotification(pushKey.pushKey, + utf8.encode(jsonEncode(data))); + payload = { + 'push_key_id': pushKey.pushKeyId.toString(), + 'encrypted_data': base64Encode(encrypted), + }; + } + return RemoteMessage(data: payload); +} + MessageFcmMessage messageFcmMessage( Message zulipMessage, { String? streamName, @@ -108,11 +161,17 @@ void main() { return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess); } + Future addAccount(Account account) async { + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await testBinding.globalStore.pushKeys.perAccount(account.id).insertPushKey( + eg.pushKey(account: account).toCompanion(false)); + } + Future init({bool addSelfAccount = true}) async { + addTearDown(testBinding.reset); if (addSelfAccount) { - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await addAccount(eg.selfAccount); } - addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; @@ -453,6 +512,7 @@ void main() { FakeAsync async, MessageFcmMessage data, { Account? account, + bool encrypted = true, required String expectedTitle, required String expectedTagComponent, required bool expectedIsGroupConversation, @@ -462,7 +522,7 @@ void main() { // the logic in `NotificationService` that listens for these FCM messages. testBinding.firebaseMessaging.onMessage.add( - RemoteMessage(data: data.toJson())); + await encodeFcmMessage(data, encrypted: encrypted)); async.flushMicrotasks(); checkNotification(data, account: account, @@ -473,7 +533,7 @@ void main() { testBinding.androidNotificationHost.clearActiveNotifications(); testBinding.firebaseMessaging.onBackgroundMessage.add( - RemoteMessage(data: data.toJson())); + await encodeFcmMessage(data, encrypted: encrypted)); async.flushMicrotasks(); checkNotification(data, account: account, @@ -483,9 +543,11 @@ void main() { expectedTagComponent: expectedTagComponent); } - void receiveFcmMessage(FakeAsync async, FcmMessage data) { + Future receiveFcmMessage(FakeAsync async, FcmMessageWithIdentity data, { + bool encrypted = true, + }) async { testBinding.firebaseMessaging.onMessage.add( - RemoteMessage(data: data.toJson())); + await encodeFcmMessage(data, encrypted: encrypted)); async.flushMicrotasks(); } @@ -521,6 +583,18 @@ void main() { expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); }))); + test('stream message, legacy plaintext', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(addSelfAccount: false); + await addAccount(eg.selfAccount); + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await checkNotifications(async, messageFcmMessage(message, streamName: stream.name), + encrypted: false, + expectedIsGroupConversation: true, + expectedTitle: '#${stream.name} > ${message.topic}', + expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); + }))); + test('stream message: multiple messages, same topic', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); @@ -535,21 +609,21 @@ void main() { final expectedTitle = '#${stream.name} > $topic'; final expectedTagComponent = 'stream:${stream.streamId}:$topic'; - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: true, expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: true, expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); - receiveFcmMessage(async, data3); + await receiveFcmMessage(async, data3); checkNotification(data3, messageStyleMessages: [data1, data2, data3], expectedIsGroupConversation: true, @@ -569,21 +643,21 @@ void main() { final message3 = eg.streamMessage(topic: topicA, stream: stream); final data3 = messageFcmMessage(message3, streamName: stream.name); - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topicA', expectedTagComponent: 'stream:${stream.streamId}:${topicA.toLowerCase()}'); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data2], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topicB', expectedTagComponent: 'stream:${stream.streamId}:${topicB.toLowerCase()}'); - receiveFcmMessage(async, data3); + await receiveFcmMessage(async, data3); checkNotification(data3, messageStyleMessages: [data1, data3], expectedIsGroupConversation: true, @@ -601,14 +675,14 @@ void main() { final message2 = eg.streamMessage(topic: topic2, stream: stream); final data2 = messageFcmMessage(message2, streamName: stream.name); - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > $topic1', expectedTagComponent: 'stream:${stream.streamId}:a topic'); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: true, @@ -624,7 +698,7 @@ void main() { final message1 = eg.streamMessage(topic: topic, stream: stream); final data1 = messageFcmMessage(message1, streamName: stream.name); - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: true, @@ -635,7 +709,7 @@ void main() { final message2 = eg.streamMessage(topic: topic, stream: stream); final data2 = messageFcmMessage(message2, streamName: stream.name); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: true, @@ -661,13 +735,14 @@ void main() { user: eg.user(), realmUrl: Uri.parse('http://realm1.example'), realmName: 'Realm 1'); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await addAccount(account1); + final account2 = eg.account( id: 1002, user: eg.user(), realmUrl: Uri.parse('http://realm2.example'), realmName: 'Realm 2'); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); + await addAccount(account2); final stream = eg.stream(); final topic = 'test topic'; @@ -678,7 +753,7 @@ void main() { eg.streamMessage(stream: stream, topic: topic), account: account2, streamName: stream.name); - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, account: account1, messageStyleMessages: [data1], @@ -687,7 +762,7 @@ void main() { expectedTagComponent: 'stream:${stream.streamId}:$topic', expectedSummaryText: account1.realmName); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, account: account2, messageStyleMessages: [data2], @@ -704,7 +779,8 @@ void main() { id: 1001, user: eg.user(), realmUrl: Uri.parse('http://realm1.example')); - await testBinding.globalStore.add(account, eg.initialSnapshot()); + await addAccount(account); + // Override the default realmName from eg.account(). account = await testBinding.globalStore.updateAccount(account.id, AccountsCompanion(realmName: const Value(null))); @@ -719,7 +795,7 @@ void main() { realmName: 'Notif realm name'); check(data).realmName.equals('Notif realm name'); - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); checkNotification(data, account: account, messageStyleMessages: [data], @@ -736,7 +812,8 @@ void main() { id: 1001, user: eg.user(), realmUrl: Uri.parse('http://realm1.example')); - await testBinding.globalStore.add(account, eg.initialSnapshot()); + await addAccount(account); + // Override the default realmName from eg.account(). account = await testBinding.globalStore.updateAccount(account.id, AccountsCompanion(realmName: const Value(null))); @@ -749,7 +826,7 @@ void main() { account: account, streamName: stream.name); check(data).realmName.isNull(); - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); checkNotification(data, account: account, messageStyleMessages: [data], @@ -787,14 +864,14 @@ void main() { final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: true, expectedTitle: "${eg.otherUser.fullName} to you and 1 other", expectedTagComponent: expectedTagComponent); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: true, @@ -819,7 +896,7 @@ void main() { final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: false, @@ -830,7 +907,7 @@ void main() { final message2 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); final data2 = messageFcmMessage(message2); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: false, @@ -846,7 +923,7 @@ void main() { final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; - receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data1); checkNotification(data1, messageStyleMessages: [data1], expectedIsGroupConversation: false, @@ -857,7 +934,7 @@ void main() { final message2 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); final data2 = messageFcmMessage(message2); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data2); checkNotification(data2, messageStyleMessages: [data1, data2], expectedIsGroupConversation: false, @@ -870,7 +947,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); final data = messageFcmMessage(message); - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); checkNotification(data, messageStyleMessages: [data], expectedIsGroupConversation: false, @@ -886,7 +963,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); final data = messageFcmMessage(message); - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); checkNotification(data, messageStyleMessages: [data], expectedIsGroupConversation: false, @@ -915,24 +992,44 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); // Check on foreground event; onMessage - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data, 'stream:${message.streamId}:${message.topic}'), conditionSummaryActiveNotif(expectedGroupKey), ]); testBinding.firebaseMessaging.onMessage.add( - RemoteMessage(data: removeFcmMessage([message]).toJson())); + await encodeFcmMessage(removeFcmMessage([message]))); async.flushMicrotasks(); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); // Check on background event; onBackgroundMessage - receiveFcmMessage(async, data); + await receiveFcmMessage(async, data); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data, 'stream:${message.streamId}:${message.topic}'), conditionSummaryActiveNotif(expectedGroupKey), ]); testBinding.firebaseMessaging.onBackgroundMessage.add( - RemoteMessage(data: removeFcmMessage([message]).toJson())); + await encodeFcmMessage(removeFcmMessage([message]))); + async.flushMicrotasks(); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); + + test('remove: smoke; legacy plaintext', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(addSelfAccount: false); + await addAccount(eg.selfAccount); + final message = eg.streamMessage(); + final data = messageFcmMessage(message); + final expectedGroupKey = '${data.realmUrl}|${data.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + await receiveFcmMessage(async, data, encrypted: false); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data, 'stream:${message.streamId}:${message.topic}'), + conditionSummaryActiveNotif(expectedGroupKey), + ]); + testBinding.firebaseMessaging.onMessage.add( + await encodeFcmMessage(removeFcmMessage([message]), encrypted: false)); async.flushMicrotasks(); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); @@ -952,23 +1049,23 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); - receiveFcmMessage(async, data1); - receiveFcmMessage(async, data2); - receiveFcmMessage(async, data3); + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data3); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data3, conversationKey), conditionSummaryActiveNotif(expectedGroupKey), ]); // A RemoveFcmMessage for the first two messages; the notification stays. - receiveFcmMessage(async, removeFcmMessage([message1, message2])); + await receiveFcmMessage(async, removeFcmMessage([message1, message2])); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data3, conversationKey), conditionSummaryActiveNotif(expectedGroupKey), ]); // Then a RemoveFcmMessage for the last message; clear the notification. - receiveFcmMessage(async, removeFcmMessage([message3])); + await receiveFcmMessage(async, removeFcmMessage([message3])); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); @@ -990,8 +1087,8 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); // Two notifications for different conversations; but same account. - receiveFcmMessage(async, data1); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data1, conversationKey1), conditionSummaryActiveNotif(expectedGroupKey), @@ -999,7 +1096,7 @@ void main() { ]); // A RemoveFcmMessage for first conversation; only clears the first conversation notif. - receiveFcmMessage(async, removeFcmMessage([message1])); + await receiveFcmMessage(async, removeFcmMessage([message1])); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionSummaryActiveNotif(expectedGroupKey), conditionActiveNotif(data2, conversationKey2), @@ -1007,7 +1104,7 @@ void main() { // Then a RemoveFcmMessage for the only remaining conversation; // clears both the conversation notif and summary notif. - receiveFcmMessage(async, removeFcmMessage([message2])); + await receiveFcmMessage(async, removeFcmMessage([message2])); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); @@ -1022,7 +1119,7 @@ void main() { realmUrl: Uri.parse('https://1.chat.example'), id: 1001, user: eg.user(userId: 1001)); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await addAccount(account1); final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data1 = messageFcmMessage(message1, account: account1, streamName: stream.name); @@ -1032,7 +1129,7 @@ void main() { realmUrl: Uri.parse('https://2.chat.example'), id: 1002, user: eg.user(userId: 1001)); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); + await addAccount(account2); final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data2 = messageFcmMessage(message2, account: account2, streamName: stream.name); @@ -1040,8 +1137,8 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); - receiveFcmMessage(async, data1); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data1, conversationKey), conditionSummaryActiveNotif(groupKey1), @@ -1049,13 +1146,13 @@ void main() { conditionSummaryActiveNotif(groupKey2), ]); - receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); + await receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); - receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); + await receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); @@ -1067,14 +1164,14 @@ void main() { final conversationKey = 'stream:${stream.streamId}:some topic'; final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); + await addAccount(account1); final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data1 = messageFcmMessage(message1, account: account1, streamName: stream.name); final groupKey1 = '${account1.realmUrl}|${account1.userId}'; final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); + await addAccount(account2); final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); final data2 = messageFcmMessage(message2, account: account2, streamName: stream.name); @@ -1082,8 +1179,8 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); - receiveFcmMessage(async, data1); - receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data1, conversationKey), conditionSummaryActiveNotif(groupKey1), @@ -1091,20 +1188,20 @@ void main() { conditionSummaryActiveNotif(groupKey2), ]); - receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); + await receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ conditionActiveNotif(data2, conversationKey), conditionSummaryActiveNotif(groupKey2), ]); - receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); + await receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); test('removeNotificationsForAccount: removes notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - receiveFcmMessage(async, messageFcmMessage(message)); + await receiveFcmMessage(async, messageFcmMessage(message)); check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty(); await NotificationDisplayManager.removeNotificationsForAccount( @@ -1118,15 +1215,15 @@ void main() { final realmUrl = eg.realmUrl; final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl); final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); + await addAccount(account1); + await addAccount(account2); check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); final message1 = eg.streamMessage(); final message2 = eg.streamMessage(); - receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); - receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); + await receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); + await receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); check(testBinding.androidNotificationHost.activeNotifications) .length.equals(4); @@ -1147,13 +1244,13 @@ void main() { final account2 = eg.account( id: 1002, user: eg.user(userId: userId), realmUrl: Uri.parse('https://realm2.example')); - await testBinding.globalStore.add(account1, eg.initialSnapshot()); - await testBinding.globalStore.add(account2, eg.initialSnapshot()); + await addAccount(account1); + await addAccount(account2); final message1 = eg.streamMessage(); final message2 = eg.streamMessage(); - receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); - receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); + await receiveFcmMessage(async, messageFcmMessage(message1, account: account1)); + await receiveFcmMessage(async, messageFcmMessage(message2, account: account2)); check(testBinding.androidNotificationHost.activeNotifications) .length.equals(4); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0d4b4d65c2..962a4e58ac 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + SodiumLibsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4a4d9be3e7..d53f8ff9e4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core share_plus + sodium_libs sqlite3_flutter_libs url_launcher_windows )