Skip to content

notif ios: Decrypt E2EE notifications#2230

Open
rajveermalviya wants to merge 12 commits intozulip:mainfrom
rajveermalviya:pr-ios-e2ee
Open

notif ios: Decrypt E2EE notifications#2230
rajveermalviya wants to merge 12 commits intozulip:mainfrom
rajveermalviya:pr-ios-e2ee

Conversation

@rajveermalviya
Copy link
Copy Markdown
Member

@rajveermalviya rajveermalviya commented Mar 17, 2026

Stacked on top of #2156.

This moves IPHONEOS_DEPLOYMENT_TARGET config to Zulip.xcconfig,
instead of it being specified for different Xcode config sections.

This vastly simplifies changing the iOS deployment target later,
allowing us to change a single variable, instead of navigating
through the Xcode UI to change for multiple targets (two currently;
Runner and RunnerTests).

See: zulip#2156 (comment)
Similar to previous commit, this moves DEVELOPMENT_TEAM config to
Zulip.xcconfig, instead of it being specified for different Xcode
config sections.
In the Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'.
Carries out the steps 3-4 from "Add iOS app extension" section in
the Flutter docs:
  https://docs.flutter.dev/platform-integration/ios/app-extensions#add-extension

In step 3, the Flutter docs walk through adding a "Share Extension"
target, we instead add a "Notification Service Extension" target,
as described in Apple's documentation:
  https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications#Add-a-service-app-extension-to-your-project

The Swift file generated in step 3 was formatted using
`swift-format` (In Xcode menu bar, Editor -> Structure -> Format
file with 'swift-format').

Also carries out step 5 from the "Open a Flutter app in an iOS app
extension" section in the Flutter docs:
  https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter
this will share the build configurations between the extension
target and the Runner target, allowing the extension target to:
- Inherit the IPHONEOS_DEPLOYMENT_TARGET config value,
  from Zulip.xcconfig which {Debug,Release}.xcconfig imports.
- Also it pulls in some build config specified by Flutter,
  including some CocoaPods-related stuff; see for example
  ios/Flutter/Release.xcconfig.
See discussion: zulip#2156 (comment)

Also made following other changes to Xcode build settings for the
generated NotificationService target:
- Deleted "iOS Deployment Target" config specified for target,
  so that when building the target, the inherited value from
  Zulip.xcconfig will be used. By navigating to project navigator
  (View > Navigators > Project) > NotificationService under TARGETS
  > Build Settings (tab) > Deployment, then pressed the Delete key
  on keyboard after selecting "iOS Deployment Target".

- Updated the versioning information to match the Runner target.
  By navigating to project navigator (View > Navigators > Project)
  > NotificationService under TARGETS > Build Settings (tab) >
  Versioning, and changing the following values:
  * Changed "Current Project Version" to "$(FLUTTER_BUILD_NUMBER)"
    (in Xcode text field, typed without the double-quotes),
    where the generated value was "1".
  * Changed "Marketing Version" to "$(FLUTTER_BUILD_NAME)" (same as
    previous one, no double-quotes), where the default value was
    "1.0".
  * Changed "Versioning System" to "Apple Generic", where the
    default was set to "None".
  See discussion: zulip#2156 (comment)
…xtension

Implement initial setup of a headless FlutterEngine for running Dart
code in the NotificationService extension.
This involves some Swift code to start the engine and drive its event loop,
and a trivial Dart function to serve as the entry point.
It also involves some build setup.

For the build setup, this carries out the steps 2-3 from the "Register plugins"
section in Flutter's guide to iOS app extensions:
  https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins

and adds the following pre-action script for the Runner scheme:
  /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare
by following same steps listed under step 4 in "Open a Flutter app
in an iOS app extension" section in Flutter's guide to iOS app
extensions:
  https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter
but for the Runner scheme instead of the extension scheme. And also
selected the Runner target instead of the extension target for the
"Provide build settings" drop-down list.

That script copies the Flutter framework to a place where Xcode
will find it when building the NotificationService target.
See discussion: zulip#2156 (comment)

We are skipping the step 3 of "Open a Flutter app in an iOS app
extension":
  https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter
because disabling user script sandboxing is only needed if we had
added a "Run Script" build phase without specifying input and
output files. It seems it is unnecessary for the pre-action script
because Xcode UI for adding the script doesn't have the options for
specifying input and output files.
See discussion: zulip#2156 (comment)
Fixes zulip#1265.

This uses Pigeons's handy `@FlutterApi` API to define an interface
method that allows passing arguments from Swift to Dart and also
get a return value back from Dart to Swift.
…roup

This is required to share any files like database between both
app (Runner) and the service extension targets.
This will allow the Dart code executing in the notification service
extension to access the same database file as the main app target.
This is needed for (soon to be added) NotificationService target to
initialize the Pigeon API.
These types will soon be used to parse encrypted data from iOS APNs
payloads. So, rename them to be neutral about the push notification
service they come from.
@rajveermalviya rajveermalviya force-pushed the pr-ios-e2ee branch 3 times, most recently from 869495a to 9616f4a Compare March 24, 2026 16:12
@rajveermalviya rajveermalviya marked this pull request as ready for review March 24, 2026 16:14
@rajveermalviya rajveermalviya added the maintainer review PR ready for review by Zulip maintainers label Mar 24, 2026
@chrisbobbe
Copy link
Copy Markdown
Collaborator

chrisbobbe commented Mar 26, 2026

Thanks! I'm not yet convinced about the approach in the rename commit:

21c73b897 notif [nfc]: Rename FcmMessage (and subtypes) to NotifMessage

I started a CZO discussion about it: #mobile-team > E2EE: parsing code @ 💬#mobile-team > E2EE: parsing code @ 💬

Comment on lines +54 to +55
Future<ImprovedNotificationContent> _didReceivePushNotification(NotificationContent notifContent) async {
final parsed = EncryptedApnsPayload.fromJson(notifContent.payload.cast());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens when this receives a legacy notification? Does it just throw?

That might have the right behavior already, because IIUC we don't actually need to do anything with the legacy notification. But we should handle it explicitly; it shouldn't be treated as an error.

Copy link
Copy Markdown
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks @rajveermalviya for building this!

Comments below. See also the chat thread for Chris's comment above.

One high-level observation, which several of these comments add detail to: the main commit:
b5abc3b notif ios: Decrypt E2EE notifications
is doing too many things at once. It should be broken up into several separate commits, in order to make each of its changes more readable by disentangling them from the others.

B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; };
B35E11A62F484E6800DE4085 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
B378A5012F45B08F0031EFA1 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B3D425322F6D40C200F9AE69 /* IosNative.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340EB372F5B092B007AD309 /* IosNative.g.swift */; };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, this line is added in this commit:
0270431 notif: Setup IosNativeHostApi Pigeon API for NotificationService headless Flutter engine

But that file isn't new (or even touched) in that commit; in fact it's already present in main, added originally in 1d03e57 (#2195).

What caused this line to appear in this particular commit?

Should this line have been present all along? What's been the effect of it not being present?

B35E11A62F484E6800DE4085 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
B378A5012F45B08F0031EFA1 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B3D425322F6D40C200F9AE69 /* IosNative.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340EB372F5B092B007AD309 /* IosNative.g.swift */; };
B3D425332F6D40C200F9AE69 /* IosNativeHostApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */; };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This line is added in this commit (same as the comment above):
0270431 notif: Setup IosNativeHostApi Pigeon API for NotificationService headless Flutter engine

But that file isn't new in that commit, or touched there; it was introduced in the previous commit:
9882e18 ios [nfc]: Move IosNativeHostApiImpl to its own Swift file

Should this line have been added in that commit? What caused it to appear in this file?

It also looks quite a lot like another line a few lines above:

               B32717692F6C49E5007682B1 /* IosNativeHostApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */; };

which does appear in that earlier commit. What's the effect of having both lines, instead of just the line that was added later? Should we have only one of them?

/// Parsed version of an FCM message, of any plaintext type.
/// An APNs payload whose contents are encrypted end-to-end from the Zulip server.
///
/// Once decrypted, the contents will become an [NotifMessage].
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
/// Once decrypted, the contents will become an [NotifMessage].
/// Once decrypted, the contents will become a [NotifMessage].

(similarly above. "Notif" starts with a consonant sound, while "Fcm" starts with a vowel sound: "eff cee em".)

final String token;
final int timestamp;
// final String? iosAppId; // TODO(#1764)
final String? iosAppId;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can be easily split out into a prep commit.

Comment on lines +407 to +421
static String titleForNotifMessage(MessageNotifMessage data, ZulipLocalizations zulipLocalizations) {
return switch (data.recipient) {
NotifMessageChannelRecipient(:var channelName?, :var topic) =>
'#$channelName > ${topic.displayName}',
NotifMessageChannelRecipient(:var topic) =>
'#${zulipLocalizations.unknownChannelName} > ${topic.displayName}', // TODO get stream name from data
NotifMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 =>
zulipLocalizations.notifGroupDmConversationLabel(
data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data
NotifMessageDmRecipient() =>
data.senderFullName,
};
}

static Uri notificationUrlForNotifMessage(MessageNotifMessage data) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This factoring-out can happen as its own prep commit.

Comment on lines +61 to +62
Future<void> checkNotification(
FakeAsync async,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This parameter doesn't seem to get used.

The use of FakeAsync is a significant complication to thinking about the tests. So if it's not getting used, best to leave it out.

Comment on lines +183 to +186
payload = {
// Only `notification_url` value is read when notification is opened.
...fakeEncryptedApnsPayload(),
'notification_url': notificationUrl.toString(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems like a further sign that the original payload doesn't belong in the data used for opening the notification 🙂

Comment on lines +216 to -192
final Map<Object?, Object?> payload;
if (encrypted) {
final notificationUrl = notificationUrlForMessage(account, message).toString();
payload = {
// Only `notification_url` value is read when notification is opened.
...fakeEncryptedApnsPayload(),
'notification_url': notificationUrl,
};
} else {
payload = messageLegacyApnsPayload(message, account: account);
}

// Set up a value to return for
// `notificationPigeonApi.getNotificationDataFromLaunch`.
final payload = messageApnsPayload(message, account: account);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This logic should all get moved into a helper next to messageLegacyApnsPayload — say, called messageApnsPayload. That way (a) we don't have two copies of it, here and just above; (b) those details are out of the way when reading these two helpers.

Comment on lines +282 to +286
await checkOpenNotification(
tester,
eg.selfAccount,
eg.streamMessage(),
encrypted: false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
await checkOpenNotification(
tester,
eg.selfAccount,
eg.streamMessage(),
encrypted: false);
await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage(),
encrypted: false);

That's both more compact, and more parallel to the test case above — makes it easier to compare them by eye and spot the one difference.

await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage());
}, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

testWidgets('iOS: stream message, legacy plaintext', (tester) async {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: "iOS legacy plaintext" is one concept here (at least, "legacy plaintext" is only meaningful within the context of "iOS", since it's about the way we were encoding things on iOS notifications specifically), so put it in one place in the description:

Suggested change
testWidgets('iOS: stream message, legacy plaintext', (tester) async {
testWidgets('stream message: iOS legacy plaintext', (tester) async {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants