Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Sodium> sodiumInit();

/// Initialize Firebase, to use for notifications.
///
/// This wraps [firebase_core.Firebase.initializeApp].
Expand Down Expand Up @@ -477,6 +484,9 @@ class LiveZulipBinding extends ZulipBinding {
return _syncPackageInfo;
}

@override
Future<Sodium> sodiumInit() => SodiumInit.init();

@override
Future<void> firebaseInitializeApp({
required firebase_core.FirebaseOptions options}) {
Expand Down
33 changes: 33 additions & 0 deletions lib/model/push_key.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Uint8List> 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);
}
}
28 changes: 9 additions & 19 deletions lib/notifications/display.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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';
Expand Down Expand Up @@ -216,33 +216,23 @@ class NotificationDisplayManager {
await NotificationChannelManager.ensureChannel();
}

static void onFcmMessage(FcmMessage data, Map<String, dynamic> 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<void> _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) async {
static Future<void> _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);

Expand Down Expand Up @@ -365,7 +355,7 @@ class NotificationDisplayManager {
);
}

static void _onRemoveFcmMessage(RemoveFcmMessage data) async {
static Future<void> _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
Expand Down
64 changes: 61 additions & 3 deletions lib/notifications/receive.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';

Expand All @@ -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';

Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will all notifications be E2EE at server 12+, or is it an optional feature? (looking at the TODO(server-12))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All notifications will be E2EE at server 12+ for clients that support it. So when we drop support for older servers, we can drop support for the legacy non-E2EE notifications.

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)!;
Comment on lines +221 to +228
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One or the other of these can throw in the case of a notification received for a logged-out account, right? (The Exception("unknown pushKeyId") or the .getAccount(…)!.) Do we instead want an early return with a comment, like in _onPlaintextRemoteMessage?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ! should never throw, because PushKeys.accountId is a foreign key with cascading deletes: when an Account record is deleted from the database, any corresponding PushKey records will be too.

Good point that an unknown push key can occur if the account was logged out. I'll have it return instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrote this:

    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;
    }

That TODO is already handled later in my draft branch.


final plaintext = await PushKeyStore.decryptNotification(
pushKey.pushKey, parsed.encryptedData);
final rawData = jsonUtf8Decoder.convert(plaintext) as Map<String, dynamic>;
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<void> _onPlaintextRemoteMessage(Map<String, dynamic> 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);
}
}
4 changes: 4 additions & 0 deletions linux/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
#include "generated_plugin_registrant.h"

#include <file_selector_linux/file_selector_plugin.h>
#include <sodium_libs/sodium_libs_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>

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);
Expand Down
1 change: 1 addition & 0 deletions linux/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
sodium_libs
sqlite3_flutter_libs
url_launcher_linux
)
Expand Down
2 changes: 2 additions & 0 deletions macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand Down
32 changes: 32 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

@chrisbobbe chrisbobbe Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit-message nit:

deps: Add sodium_libs

Just `flutter pub add sodium_libs` and then fixing up the sorting
in pubspec.yaml.

Looks like the "then fixing up the sorting" part is outdated; you fixed up the sorting in a separate prep commit. :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What that's intended to mean is that flutter pub add puts the new library at the end of the list, so I moved it to the right spot.

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:
Expand Down
6 changes: 4 additions & 2 deletions test/model/actions_test.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();

Expand Down
Loading