diff --git a/ios/NotificationService/IosNotifications.g.swift b/ios/NotificationService/IosNotifications.g.swift index 1b4a68b2e..41e5000ba 100644 --- a/ios/NotificationService/IosNotifications.g.swift +++ b/ios/NotificationService/IosNotifications.g.swift @@ -140,24 +140,37 @@ struct NotificationContent: Hashable { struct ImprovedNotificationContent: Hashable { /// The new title to use for the notification. var title: String + /// The new subtitle to use for the notification. + var subtitle: String /// The new body to use for the notification. var body: String + /// The internal data to attach with the new notification. + /// + /// This replaces the raw APNs payload that was initially set from + /// the remote push notification. + var userInfo: [String: Any?] // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> ImprovedNotificationContent? { let title = pigeonVar_list[0] as! String - let body = pigeonVar_list[1] as! String + let subtitle = pigeonVar_list[1] as! String + let body = pigeonVar_list[2] as! String + let userInfo = pigeonVar_list[3] as! [String: Any?] return ImprovedNotificationContent( title: title, - body: body + subtitle: subtitle, + body: body, + userInfo: userInfo ) } func toList() -> [Any?] { return [ title, + subtitle, body, + userInfo, ] } static func == (lhs: ImprovedNotificationContent, rhs: ImprovedNotificationContent) -> Bool { diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 11cf12040..474362a54 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -44,6 +44,9 @@ class NotificationService: UNNotificationServiceExtension { return } + IosNativeHostApiSetup.setUp( + binaryMessenger: headlessEngine.binaryMessenger, api: IosNativeHostApiImpl()) + // Register Flutter plugins with the headless engine. GeneratedPluginRegistrant.register(with: headlessEngine) @@ -60,7 +63,9 @@ class NotificationService: UNNotificationServiceExtension { switch result { case .success(let improvedNotificationContent): bestAttemptContent.title = improvedNotificationContent.title + bestAttemptContent.subtitle = improvedNotificationContent.subtitle bestAttemptContent.body = improvedNotificationContent.body + bestAttemptContent.userInfo = improvedNotificationContent.userInfo as [AnyHashable : Any] contentHandler(bestAttemptContent) case .failure(let error): // TODO(log) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7005e07d7..6b55b0667 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,10 +13,13 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + B32717692F6C49E5007682B1 /* IosNativeHostApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */; }; B340EB382F5B092B007AD309 /* IosNative.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340EB372F5B092B007AD309 /* IosNative.g.swift */; }; 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 */; }; + B3D425332F6D40C200F9AE69 /* IosNativeHostApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -84,6 +87,7 @@ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosNativeHostApi.swift; sourceTree = ""; }; B340EB372F5B092B007AD309 /* IosNative.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosNative.g.swift; sourceTree = ""; }; B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -212,6 +216,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */, B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -569,6 +574,7 @@ B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, B340EB382F5B092B007AD309 /* IosNative.g.swift in Sources */, + B32717692F6C49E5007682B1 /* IosNativeHostApi.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -576,6 +582,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B3D425322F6D40C200F9AE69 /* IosNative.g.swift in Sources */, + B3D425332F6D40C200F9AE69 /* IosNativeHostApi.swift in Sources */, B35E11A62F484E6800DE4085 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 436c937d5..d8010d6c3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -87,13 +87,3 @@ class NotificationTapEventListener: NotificationTapEventsStreamHandler { eventSink?.success(IosNotificationTapEvent(payload: payload)) } } - -private class IosNativeHostApiImpl: IosNativeHostApi { - func setExcludedFromBackup(filePath: String) throws { - var resourceValues = URLResourceValues() - resourceValues.isExcludedFromBackup = true - - var url = URL(fileURLWithPath: filePath, isDirectory: false) - try url.setResourceValues(resourceValues) - } -} diff --git a/ios/Runner/IosNativeHostApi.swift b/ios/Runner/IosNativeHostApi.swift new file mode 100644 index 000000000..2072d22d3 --- /dev/null +++ b/ios/Runner/IosNativeHostApi.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit + +public class IosNativeHostApiImpl: IosNativeHostApi { + func setExcludedFromBackup(filePath: String) throws { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + + var url = URL(fileURLWithPath: filePath, isDirectory: false) + try url.setResourceValues(resourceValues) + } +} diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart index 14cc06fba..98e91168d 100644 --- a/lib/api/notifications.dart +++ b/lib/api/notifications.dart @@ -35,6 +35,29 @@ class EncryptedFcmMessage { Map toJson() => _$EncryptedFcmMessageToJson(this); } +/// An APNs payload whose contents are encrypted end-to-end from the Zulip server. +/// +/// Once decrypted, the contents will become a [NotifPayload]. +/// +/// API docs: +/// https://zulip.com/api/mobile-notifications#data-sent-to-apns +@JsonSerializable(fieldRename: FieldRename.snake) +class EncryptedApnsPayload { + final int pushKeyId; + + @JsonKey(fromJson: base64Decode, toJson: base64Encode) + final Uint8List encryptedData; + + // final Map aps; // ignore; never used + + EncryptedApnsPayload({required this.pushKeyId, required this.encryptedData}); + + factory EncryptedApnsPayload.fromJson(Map json) => + _$EncryptedApnsPayloadFromJson(json); + + Map toJson() => _$EncryptedApnsPayloadToJson(this); +} + //|////////////////////////////////////////////////////////////// // Types for parsing E2EE notification payloads. // diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart index 9f86a4ec6..84d6054b5 100644 --- a/lib/api/notifications.g.dart +++ b/lib/api/notifications.g.dart @@ -21,6 +21,20 @@ Map _$EncryptedFcmMessageToJson( 'encrypted_data': base64Encode(instance.encryptedData), }; +EncryptedApnsPayload _$EncryptedApnsPayloadFromJson( + Map json, +) => EncryptedApnsPayload( + pushKeyId: (json['push_key_id'] as num).toInt(), + encryptedData: base64Decode(json['encrypted_data'] as String), +); + +Map _$EncryptedApnsPayloadToJson( + EncryptedApnsPayload instance, +) => { + 'push_key_id': instance.pushKeyId, + 'encrypted_data': base64Encode(instance.encryptedData), +}; + NotifPayloadNewMessage _$NotifPayloadNewMessageFromJson( Map json, ) => NotifPayloadNewMessage( diff --git a/lib/api/route/notifications.dart b/lib/api/route/notifications.dart index 5df6549ad..4cb3c2df3 100644 --- a/lib/api/route/notifications.dart +++ b/lib/api/route/notifications.dart @@ -73,12 +73,15 @@ class PushRegistration { final PushTokenKind tokenKind; final String token; final int timestamp; - // final String? iosAppId; // TODO(#1764) + + @JsonKey(includeIfNull: false) + final String? iosAppId; PushRegistration({ required this.tokenKind, required this.token, required this.timestamp, + required this.iosAppId, }); Map toJson() => _$PushRegistrationToJson(this); diff --git a/lib/api/route/notifications.g.dart b/lib/api/route/notifications.g.dart index 2b053172b..8d1b07730 100644 --- a/lib/api/route/notifications.g.dart +++ b/lib/api/route/notifications.g.dart @@ -13,6 +13,7 @@ Map _$PushRegistrationToJson(PushRegistration instance) => 'token_kind': instance.tokenKind, 'token': instance.token, 'timestamp': instance.timestamp, + 'ios_app_id': ?instance.iosAppId, }; const _$PushTokenKindEnumMap = { diff --git a/lib/host/ios_notifications.g.dart b/lib/host/ios_notifications.g.dart index 99a0e4a6b..4cd90afb4 100644 --- a/lib/host/ios_notifications.g.dart +++ b/lib/host/ios_notifications.g.dart @@ -79,19 +79,32 @@ class NotificationContent { class ImprovedNotificationContent { ImprovedNotificationContent({ required this.title, + required this.subtitle, required this.body, + required this.userInfo, }); /// The new title to use for the notification. String title; + /// The new subtitle to use for the notification. + String subtitle; + /// The new body to use for the notification. String body; + /// The internal data to attach with the new notification. + /// + /// This replaces the raw APNs payload that was initially set from + /// the remote push notification. + Map userInfo; + List _toList() { return [ title, + subtitle, body, + userInfo, ]; } @@ -102,7 +115,9 @@ class ImprovedNotificationContent { result as List; return ImprovedNotificationContent( title: result[0]! as String, - body: result[1]! as String, + subtitle: result[1]! as String, + body: result[2]! as String, + userInfo: (result[3] as Map?)!.cast(), ); } diff --git a/lib/model/push_device.dart b/lib/model/push_device.dart index 0a21b47eb..a20ee9de0 100644 --- a/lib/model/push_device.dart +++ b/lib/model/push_device.dart @@ -60,8 +60,7 @@ class PushDeviceManager extends PerAccountStoreBase { /// This is an entry in [devices]. ClientDevice? get thisDevice => _devices[account.deviceId]; - bool get _e2eeAvailable => zulipFeatureLevel >= 468 // TODO(server-12) - && defaultTargetPlatform == TargetPlatform.android; // TODO(#1764) + bool get _e2eeAvailable => zulipFeatureLevel >= 468; // TODO(server-12) void handleDeviceEvent(DeviceEvent event) { switch (event) { @@ -237,8 +236,15 @@ class PushDeviceManager extends PerAccountStoreBase { _ => throw StateError('unexpected platform: $defaultTargetPlatform'), }; - final pushRegistration = PushRegistration( // TODO(#1764) also iosAppId - tokenKind: tokenKind, token: token, + String? iosAppId; + if (defaultTargetPlatform == TargetPlatform.iOS) { + final packageInfo = await ZulipBinding.instance.packageInfo; + iosAppId = packageInfo!.packageName; + } + final pushRegistration = PushRegistration( + iosAppId: iosAppId, + tokenKind: tokenKind, + token: token, timestamp: timestamp); final encryptedPushRegistration = await _encryptToBouncer( bouncerPublicKey, jsonEncode(pushRegistration)); diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index e50d6dc8f..de5caa9c9 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import '../api/model/model.dart'; import '../api/notifications.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; @@ -259,17 +260,7 @@ class NotificationDisplayManager { // changed, which is a rare edge case but probably good. The main effect is that // group-DM threads (pending #794) get titled with the latest sender, rather than // the first. - messagingStyle.conversationTitle = switch (data.recipient) { - NotifPayloadChannelRecipient(:var channelName?, :var topic) => - '#$channelName > ${topic.displayName}', - NotifPayloadChannelRecipient(:var topic) => - '#${zulipLocalizations.unknownChannelName} > ${topic.displayName}', // TODO get stream name from data - NotifPayloadDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => - zulipLocalizations.notifGroupDmConversationLabel( - data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data - NotifPayloadDmRecipient() => - data.senderFullName, - }; + messagingStyle.conversationTitle = titleForNotifPayload(data, zulipLocalizations); messagingStyle.messages.add(MessagingStyleMessage( text: data.content, @@ -279,15 +270,7 @@ class NotificationDisplayManager { name: data.senderFullName, iconBitmap: await _fetchBitmap(data.senderAvatarUrl)))); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - NotifPayloadChannelRecipient(:var channelId, :var topic) => - TopicNarrow(channelId, topic), - NotifPayloadDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildAndroidNotificationUrl(); + final intentDataUrl = notificationUrlForNotifPayload(data); await _androidHost.notify( id: kNotificationId, @@ -416,11 +399,49 @@ class NotificationDisplayManager { // // Even though we enable the `autoCancel` flag for summary notification // during creation, the summary notification doesn't get auto canceled if - // child notifications are canceled programatically as done above. + // child notifications are canceled programmatically as done above. await _androidHost.cancel(tag: groupKey, id: kNotificationId); } } + static String titleForNotifPayload(NotifPayloadNewMessage data, ZulipLocalizations zulipLocalizations) { + return switch (data.recipient) { + NotifPayloadChannelRecipient(:var channelName?, :var topic) => + '#$channelName > ${topic.displayName}', + NotifPayloadChannelRecipient(:var topic) => + '#${zulipLocalizations.unknownChannelName} > ${topic.displayName}', // TODO get stream name from data + NotifPayloadDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => + zulipLocalizations.notifGroupDmConversationLabel( + data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data + NotifPayloadDmRecipient() => + data.senderFullName, + }; + } + + static String subtitleForNotifPayloadOnIos(NotifPayloadNewMessage data) { + // Adapted from server implementation: + // https://github.com/zulip/zulip/blob/11bf985d1/zerver/lib/push_notifications.py#L1087-L1124 + // TODO handle subtitle for user/group/wildcard mentions + return switch (data.recipient) { + NotifPayloadChannelRecipient() => '${data.senderFullName}:', + + // The title indicates the sender's name in both 1-1 and group DMs. + NotifPayloadDmRecipient() => '', + }; + } + + static Uri notificationUrlForNotifPayload(NotifPayloadNewMessage data) { + return NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + NotifPayloadChannelRecipient(:var channelId, :var topic) => + TopicNarrow(channelId, topic), + NotifPayloadDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildNotificationUrl(); + } + static Future removeNotificationsForAccount(Uri realmUrl, int userId) async { assert(defaultTargetPlatform == TargetPlatform.android); diff --git a/lib/notifications/ios_service.dart b/lib/notifications/ios_service.dart index 2c95896ff..edbe469ef 100644 --- a/lib/notifications/ios_service.dart +++ b/lib/notifications/ios_service.dart @@ -1,12 +1,15 @@ -import 'dart:convert'; - import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import '../api/notifications.dart'; import '../host/ios_notifications.g.dart'; import '../model/binding.dart'; +import '../model/localizations.dart'; +import 'display.dart'; import '../log.dart'; +import 'open.dart'; +import 'receive.dart'; @pragma('vm:entry-point') void iosNotificationServiceMain() { @@ -45,27 +48,65 @@ abstract class IosNotificationService { class _IosNotifFlutterApiImpl extends IosNotifFlutterApi { @override - Future didReceivePushNotification(NotificationContent content) async { - assert(debugLog("_IosNotifFlutterApiImpl.didReceivePushNotification")); - assert(debugLog("content.payload=${jsonEncode(content.payload)}")); - - final apsData = content.payload['aps'] as Map; - final alertData = apsData['alert'] as Map; - final title = alertData['title'] as String; - final body = alertData['body'] as String; - - // This doesn't ultimately have any effect: it returns the same - // title and body that the notification already had, so nothing changes. - // Moreover this code doesn't run in the first place when talking to a - // normal Zulip server, because the non-E2EE APNs payloads - // don't contain the flag `'mutable-content': 1` and so - // don't trigger the NotificationService app extension. + Future didReceivePushNotification(NotificationContent notifContent) async { + try { + return await _didReceivePushNotification(notifContent); + } catch (e, st) { + assert(debugLog("$e\n$st")); + rethrow; + } + } + + Future _didReceivePushNotification(NotificationContent notifContent) async { + // We always expect an E2EE notification here because for legacy plaintext + // notifications the iOS NotificationService extension will never execute. + // For iOS NotificationService extension to trigger the APNs payload needs + // to include `"mutable-content": 1` entry, which the Zulip Server sets + // only for the newer E2EE notifications. // - // The purpose of this code is to be a checkpoint on the way to supporting - // E2EE notifications (#1764) and more generally client-side control over - // notification behavior (#1265). It can be manually tested with - // a server-side edit to set the `mutable-content` flag, plus other steps: - // https://github.com/zulip/zulip-flutter/pull/2156#pullrequestreview-4085925962 - return ImprovedNotificationContent(title: title, body: body); + // In case we encounter a malformed payload here (or legacy plaintext + // payload for some reason), this method will throw here which in turn + // will result the NotificationService extension implementation in Swift + // to fallback and display the notification as per the incoming push + // notification content (displaying the fields in APNs payload's + // `"alert"` object). + // For E2EE notifications, the server sets that to say "New notification". + final parsed = EncryptedApnsPayload.fromJson(notifContent.payload.cast()); + + final result = await NotificationService.decryptNotification( + parsed.pushKeyId, parsed.encryptedData); + if (result == null) throw Exception(); // TODO(log) + + final (data, _) = result; + return _onNotifPayload(data); + } + + Future _onNotifPayload(NotifPayloadWithIdentity data) async { + return switch (data) { + NotifPayloadNewMessage() => _onNotifPayloadNewMessage(data), + NotifPayloadRemove() => throw Exception(), // TODO(log) + }; + } + + Future _onNotifPayloadNewMessage(NotifPayloadNewMessage data) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final title = + NotificationDisplayManager.titleForNotifPayload(data, zulipLocalizations); + final subtitle = + NotificationDisplayManager.subtitleForNotifPayloadOnIos(data); + final notificationUrl = + NotificationDisplayManager.notificationUrlForNotifPayload(data); + + return ImprovedNotificationContent( + title: title, + subtitle: subtitle, + body: data.content, + userInfo: { + // Pass the notification URL to this custom data map, so when a + // notification is opened we can read this custom map to decide + // which conversation to open. + // See NotificationOpenService (in lib/notifications/ios_service.dart). + NotificationOpenPayload.kIosNotificationUrlKey: notificationUrl.toString(), + }); } } diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index bd80dcd0e..e80adf159 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -229,6 +229,14 @@ class NotificationOpenService { ) { try { return NotificationOpenPayload.parseIosApnsPayload(payload); + } catch (e, st) { + assert(debugLog('$e\n$st')); + // Presumably a legacy, non-E2EE payload. + } + + // TODO(server-12) simplify by removing legacy payload case. + try { + return NotificationOpenPayload.parseLegacyIosApnsPayload(payload); } on FormatException catch (e, st) { assert(debugLog('$e\n$st')); final zulipLocalizations = ZulipLocalizations.of(context); @@ -243,7 +251,7 @@ class NotificationOpenService { required Uri url, }) { try { - return NotificationOpenPayload.parseAndroidNotificationUrl(url); + return NotificationOpenPayload.parseNotificationUrl(url); } on FormatException catch (e, st) { assert(debugLog('$e\n$st')); final zulipLocalizations = ZulipLocalizations.of(context); @@ -267,9 +275,35 @@ class NotificationOpenPayload { required this.narrow, }); + /// A key to set the notification URL (created via [buildNotificationUrl]) in + /// [ImprovedNotificationContent.userInfo] map, on iOS. + /// + /// We use this to determine which conversation to open by reading the + /// custom `userInfo` map of the tapped notification (in + /// [NotificationOpenService] above). + static const kIosNotificationUrlKey = 'notification_url'; + /// Parses the iOS APNs payload and retrieves the information /// required for navigation. factory NotificationOpenPayload.parseIosApnsPayload(Map payload) { + if (payload case { + // This is an internal URL added by the IosNotificationService + // (see lib/notifications/ios_service.dart). + kIosNotificationUrlKey: final String notificationUrl, + }) { + final url = Uri.tryParse(notificationUrl); + if (url == null) throw const FormatException(); + + return NotificationOpenPayload.parseNotificationUrl(url); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + /// Parses the legacy iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationOpenPayload.parseLegacyIosApnsPayload(Map payload) { if (payload case { 'zulip': { 'user_id': final int userId, @@ -330,10 +364,10 @@ class NotificationOpenPayload { } } - /// Parses the internal Android notification url, that was created using - /// [buildAndroidNotificationUrl], and retrieves the information required + /// Parses the internal notification URL that was created using + /// [buildNotificationUrl], and retrieves the information required /// for navigation. - factory NotificationOpenPayload.parseAndroidNotificationUrl(Uri url) { + factory NotificationOpenPayload.parseNotificationUrl(Uri url) { if (url case Uri( scheme: 'zulip', host: 'notification', @@ -379,7 +413,7 @@ class NotificationOpenPayload { } } - Uri buildAndroidNotificationUrl() { + Uri buildNotificationUrl() { return Uri( scheme: 'zulip', host: 'notification', diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index a9cc5dc30..3229e00cc 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -12,6 +12,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/push_key.dart'; +import '../model/store.dart'; import 'display.dart'; import 'open.dart'; @@ -226,31 +227,10 @@ class NotificationService { 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. - // (On logout we try to unregister the device, but that can fail if the - // device isn't able to reach the server at that time.) - 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 = NotifPayload.fromJson(rawData); - switch (data) { - case NotifPayloadWithIdentity(): break; - case UnexpectedNotifPayload(): 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"); - } + final result = await decryptNotification(parsed.pushKeyId, parsed.encryptedData); + if (result == null) return; + final (data, account) = result; NotificationDisplayManager.onNotifPayload(data, account); } @@ -291,4 +271,40 @@ class NotificationService { NotificationDisplayManager.onNotifPayload(data, account); } + + /// Decrypt an E2EE notification content. + /// + /// Returns a future resolving to null if it encounters an error. + static Future<(NotifPayloadWithIdentity, Account)?> decryptNotification( + int pushKeyId, + Uint8List encryptedData, + ) async { + final globalStore = await ZulipBinding.instance.getGlobalStore(); + final pushKey = globalStore.pushKeys.getPushKeyById(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. + // (On logout we try to unregister the device, but that can fail if the + // device isn't able to reach the server at that time.) + return null; // TODO(log) + } + final account = globalStore.getAccount(pushKey.accountId)!; + + final plaintext = await PushKeyStore.decryptNotification( + pushKey.pushKey, encryptedData); + final rawData = jsonUtf8Decoder.convert(plaintext) as Map; + final data = NotifPayload.fromJson(rawData); + switch (data) { + case NotifPayloadWithIdentity(): break; + case UnexpectedNotifPayload(): return null; // TODO(log) + } + + if (!(account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId)) { + assert(debugLog("bad notif payload: realm/userId fails to match push key")); + return null; // TODO(log) + } + + return (data, account); + } } diff --git a/pigeon/ios_notifications.dart b/pigeon/ios_notifications.dart index 4fbf5ba26..7f815b281 100644 --- a/pigeon/ios_notifications.dart +++ b/pigeon/ios_notifications.dart @@ -21,14 +21,25 @@ class NotificationContent { class ImprovedNotificationContent { const ImprovedNotificationContent({ required this.title, + required this.subtitle, required this.body, + required this.userInfo, }); /// The new title to use for the notification. final String title; + /// The new subtitle to use for the notification. + final String subtitle; + /// The new body to use for the notification. final String body; + + /// The internal data to attach with the new notification. + /// + /// This replaces the raw APNs payload that was initially set from + /// the remote push notification. + final Map userInfo; } /// Exposes an API from Dart code which can be called from Swift code. diff --git a/test/model/push_device_test.dart b/test/model/push_device_test.dart index 13c3a6989..0de4672b9 100644 --- a/test/model/push_device_test.dart +++ b/test/model/push_device_test.dart @@ -149,6 +149,9 @@ void main() { if (token != null) { final tokenKind = (defaultTargetPlatform == TargetPlatform.android) ? 'fcm' : 'apns'; + final iosAppId = defaultTargetPlatform == TargetPlatform.iOS + ? (await testBinding.packageInfo)!.packageName + : null; final tokenId = NotificationService.computeTokenId(token); check(request).bodyFields ..['token_kind'].equals(tokenKind) @@ -160,6 +163,7 @@ void main() { 'token_kind': tokenKind, 'token': token, 'timestamp': testBinding.utcNow().millisecondsSinceEpoch ~/ 1000, + 'ios_app_id': ?iosAppId, }); } } @@ -179,9 +183,7 @@ void main() { } } - // TODO(#1764) run some of these tests for iOS too, once e2ee enabled there - - test('initial run: send key and token', () => awaitFakeAsync((async) async { + testAndroidIos('initial run: send key and token', () => awaitFakeAsync((async) async { await prepareToken(someToken); // Start with no key and no ClientDevice ack from the server. await prepareStore(); @@ -198,7 +200,7 @@ void main() { await checkRegister(async, key: null, token: null); })); - test('have key but no ack: send key and token', () => awaitFakeAsync((async) async { + testAndroidIos('have key but no ack: send key and token', () => awaitFakeAsync((async) async { await prepareToken(someToken); final pushKey = mkKey(); await prepareStore(pushKeys: [pushKey]); @@ -218,7 +220,7 @@ void main() { await checkRegister(async, key: newKey, token: null); })); - test('resend token on error', () => awaitFakeAsync((async) async { + testAndroidIos('resend token on error', () => awaitFakeAsync((async) async { await prepareToken(someToken); final pushKey = mkKey(); await prepareStore(pushKeys: [pushKey], @@ -227,7 +229,7 @@ void main() { await checkRegister(async, key: null, token: someToken); })); - test('update token when server has old token', () => awaitFakeAsync((async) async { + testAndroidIos('update token when server has old token', () => awaitFakeAsync((async) async { await prepareToken(someToken); final pushKey = mkKey(); await prepareStore(pushKeys: [pushKey], @@ -235,7 +237,7 @@ void main() { await checkRegister(async, key: null, token: someToken); })); - test('update token when server has old token pending', () => awaitFakeAsync((async) async { + testAndroidIos('update token when server has old token pending', () => awaitFakeAsync((async) async { await prepareToken(someToken); final pushKey = mkKey(); await prepareStore(pushKeys: [pushKey], @@ -246,7 +248,7 @@ void main() { await checkRegister(async, key: null, token: someToken); })); - test('resend token when old', () => awaitFakeAsync((async) async { + testAndroidIos('resend token when old', () => awaitFakeAsync((async) async { await prepareToken(someToken); final pushKey = mkKey(); await prepareStore(pushKeys: [pushKey], @@ -255,6 +257,7 @@ void main() { await checkRegister(async, key: null, token: someToken); })); + // No corresponding iOS test, because no token updates there. test('update when token changes', () => awaitFakeAsync((async) async { // At startup, everything's up to date. await prepareToken(someToken); @@ -270,6 +273,7 @@ void main() { await checkLastRequest(key: null, token: otherToken); })); + // No corresponding iOS test, because no token updates there. test('token initially unknown; send when known', () => awaitFakeAsync((async) async { // This tests the case where the store is created while our // request for the token is still pending. diff --git a/test/model/push_key_test.dart b/test/model/push_key_test.dart index 197e4e8d3..10c80f38b 100644 --- a/test/model/push_key_test.dart +++ b/test/model/push_key_test.dart @@ -1,7 +1,6 @@ import 'package:checks/checks.dart'; import 'package:drift/drift.dart' as drift; import 'package:fake_async/fake_async.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/database.dart'; @@ -206,13 +205,6 @@ void main() { check(store.pushKeys.latestPushKey).equals(key); })); - test('on iOS, generate no key', () => awaitFakeAsync((async) async { - addTearDown(() => debugDefaultTargetPlatformOverride = null); - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - await initStore(async); - check(store.pushKeys.latestPushKey).isNull(); - })); - test('on old server, generate no key', () => awaitFakeAsync((async) async { await initStore(async, zulipFeatureLevel: 468 - 1); check(store.pushKeys.latestPushKey).isNull(); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index cc69c2a01..e502c5934 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -31,7 +31,7 @@ import '../model/store_checks.dart'; import '../test_images.dart'; import '../api/notifications_test.dart'; -Future _encryptNotification(Uint8List pushKey, Uint8List plaintext) async { +Future encryptNotification(Uint8List pushKey, Uint8List plaintext) async { // Compare [PushKeyStore.decryptNotification]. final keyBytes = PushKeyStore.secretboxKeyFromPushKey(pushKey); @@ -71,7 +71,7 @@ Future encodeFcmMessage(NotifPayloadWithIdentity data) async { && account.userId == data.userId).single; final pushKey = testBinding.globalStore.pushKeys.perAccount(account.id) .latestPushKey!; - final encrypted = await _encryptNotification(pushKey.pushKey, + final encrypted = await encryptNotification(pushKey.pushKey, utf8.encode(jsonEncode(data))); payload = { 'push_key_id': pushKey.pushKeyId.toString(), @@ -492,7 +492,7 @@ void main() { TopicNarrow(channelId, topic), NotifPayloadDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildAndroidNotificationUrl(); + }).buildNotificationUrl(); expectedSummaryText ??= account.realmName ?? data.realmName ?? data.realmUrl.toString(); diff --git a/test/notifications/ios_service_test.dart b/test/notifications/ios_service_test.dart index 1741f27cf..4321f9243 100644 --- a/test/notifications/ios_service_test.dart +++ b/test/notifications/ios_service_test.dart @@ -1,35 +1,130 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/notifications.dart'; import 'package:zulip/host/ios_notifications.g.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/narrow.dart'; import 'package:zulip/notifications/ios_service.dart'; +import 'package:zulip/notifications/open.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; -import 'open_test.dart'; +import 'display_test.dart' show encryptNotification, notifPayloadNewMessage; + +/// Encode a notification payload into the form APNs would supply it in. +/// +/// The result is suitable for passing to a method like +/// `testBinding.iosNotifFlutterApi.didReceivePushNotification`. +Future> encodeApnsPayload(NotifPayloadWithIdentity data) async { + 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))); + + return { + 'push_key_id': pushKey.pushKeyId, + 'encrypted_data': base64Encode(encrypted), + }; +} void main() { TestZulipBinding.ensureInitialized(); - test('smoke', () async { + Future addAccount(Account account, { + int? zulipFeatureLevel, + }) async { + final initialSnapshot = eg.initialSnapshot( + zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(account, 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 addAccount(eg.selfAccount); + } addTearDown(IosNotificationService.debugReset); IosNotificationService.init(); + } + + Future checkNotification( + NotifPayloadNewMessage data, { + Account? account, + required String expectedTitle, + required String expectedSubtitle, + }) async { + account ??= eg.selfAccount; + assert(account.userId == data.userId && account.realmUrl == data.realmUrl); + + final payload = await encodeApnsPayload(data); + final result = await testBinding.iosNotifFlutterApi.didReceivePushNotification( + NotificationContent(payload: payload)); - final title = 'test title'; - final content = 'test content'; - final payload = messageApnsPayload( - eg.streamMessage(content: content), - title: title); + final expectedNotificationUrl = NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + NotifPayloadChannelRecipient(:var channelId, :var topic) => + TopicNarrow(channelId, topic), + NotifPayloadDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildNotificationUrl(); - final result = await testBinding.iosNotifFlutterApi - .didReceivePushNotification(NotificationContent(payload: payload)); check(result) - ..title.equals(title) - ..body.equals(content); + ..title.equals(expectedTitle) + ..subtitle.equals(expectedSubtitle) + ..body.equals(data.content) + ..userInfo.deepEquals({ + NotificationOpenPayload.kIosNotificationUrlKey: expectedNotificationUrl.toString(), + }); + } + + test('stream message', () async { + await init(); + final sender = eg.otherUser; + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream, sender: sender); + await checkNotification( + notifPayloadNewMessage(message, streamName: stream.name), + expectedTitle: '#${stream.name} > ${message.topic}', + expectedSubtitle: '${sender.fullName}:'); + }); + + test('group DM: 3 users', () async { + await init(); + final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); + await checkNotification(notifPayloadNewMessage(message), + expectedTitle: "${eg.thirdUser.fullName} to you and 1 other", + expectedSubtitle: ''); + }); + + test('1:1 DM', () async { + await init(); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + await checkNotification(notifPayloadNewMessage(message), + expectedTitle: eg.otherUser.fullName, + expectedSubtitle: ''); + }); + + test('self-DM', () async { + await init(); + final message = eg.dmMessage(from: eg.selfUser, to: []); + await checkNotification(notifPayloadNewMessage(message), + expectedTitle: eg.selfUser.fullName, + expectedSubtitle: ''); }); } extension on Subject { Subject get title => has((x) => x.title, 'title'); - Subject get body => has((x) => x.body, 'body'); + Subject get subtitle => has((x) => x.subtitle, 'subtitle'); + Subject get body => has((x) => x.body, 'body'); + Subject> get userInfo => has((x) => x.userInfo, 'userInfo'); } diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 7be0f08e2..92e276038 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -33,7 +33,7 @@ import '../widgets/checks.dart'; import '../widgets/dialog_checks.dart'; import 'display_test.dart'; -Map messageApnsPayload( +Map messageLegacyApnsPayload( Message zulipMessage, { String? streamName, Account? account, @@ -146,7 +146,7 @@ void main() { check(pushedRoutes).isEmpty(); } - Uri androidNotificationUrlForMessage(Account account, Message message) { + Uri notificationUrlForMessage(Account account, Message message) { final data = notifPayloadNewMessage(message, account: account); return NotificationOpenPayload( realmUrl: data.realmUrl, @@ -156,19 +156,37 @@ void main() { TopicNarrow(channelId, topic), NotifPayloadDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildAndroidNotificationUrl(); + }).buildNotificationUrl(); } - Future openNotification(WidgetTester tester, Account account, Message message) async { + Map messageApnsPayload( + Account account, + Message message, { + bool encrypted = true, + }) { + if (encrypted) { + final notificationUrl = notificationUrlForMessage(account, message); + return { NotificationOpenPayload.kIosNotificationUrlKey: notificationUrl.toString() }; + } else { + return messageLegacyApnsPayload(message, account: account); + } + } + + Future openNotification( + WidgetTester tester, + Account account, + Message message, { + bool encrypted = true, + }) async { switch (defaultTargetPlatform) { case TargetPlatform.android: - final intentDataUrl = androidNotificationUrlForMessage(account, message); + final intentDataUrl = notificationUrlForMessage(account, message); testBinding.notificationPigeonApi.addNotificationTapEvent( AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator case TargetPlatform.iOS: - final payload = messageApnsPayload(message, account: account); + final payload = messageApnsPayload(account, message, encrypted: encrypted); testBinding.notificationPigeonApi.addNotificationTapEvent( IosNotificationTapEvent(payload: payload)); await tester.idle(); // let navigateForNotification find navigator @@ -178,19 +196,24 @@ void main() { } } - void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { + void setupNotificationDataForLaunch( + WidgetTester tester, + Account account, + Message message, { + bool encrypted = true, + }) { switch (defaultTargetPlatform) { case TargetPlatform.android: // Set up an event to be emitted from // `notificationPigeonApi.notificationTapEventsStream`. - final intentDataUrl = androidNotificationUrlForMessage(account, message); + final intentDataUrl = notificationUrlForMessage(account, message); testBinding.notificationPigeonApi.addNotificationTapEvent( AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); case TargetPlatform.iOS: // Set up a value to return for // `notificationPigeonApi.getNotificationDataFromLaunch`. - final payload = messageApnsPayload(message, account: account); + final payload = messageApnsPayload(account, message, encrypted: encrypted); testBinding.notificationPigeonApi.setNotificationDataFromLaunch( NotificationDataFromLaunch(payload: payload)); @@ -220,8 +243,9 @@ void main() { Account account, Message message, { bool expectHomePageReplaced = false, + bool encrypted = true, }) async { - await openNotification(tester, account, message); + await openNotification(tester, account, message, encrypted: encrypted); if (expectHomePageReplaced) { takeHomePageReplacement(account.id); } else { @@ -238,6 +262,14 @@ void main() { await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + testWidgets('stream message: iOS legacy plaintext', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage(), + encrypted: false); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + testWidgets('direct message', (tester) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -246,6 +278,15 @@ void main() { eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + testWidgets('direct message: iOS legacy plaintext', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + encrypted: false); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + testWidgets('account queried by realmUrl origin component', (tester) async { addTearDown(testBinding.reset); await testBinding.globalStore.add( @@ -353,6 +394,23 @@ void main() { matchesNavigation(check(pushedRoutes).single, account, message); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + testWidgets('at app launch: iOS legacy plaintext', (tester) async { + addTearDown(testBinding.reset); + final account = eg.selfAccount; + final message = eg.streamMessage(); + setupNotificationDataForLaunch(tester, account, message, encrypted: false); + + // Now start the app. + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeHomePageRouteForAccount(account.id); // because associated account + matchesNavigation(check(pushedRoutes).single, account, message); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + testWidgets('uses associated account as initial account; if initial route', (tester) async { addTearDown(testBinding.reset); @@ -548,8 +606,8 @@ void main() { userId: 1001, narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), ); - var url = payload.buildAndroidNotificationUrl(); - check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + var url = payload.buildNotificationUrl(); + check(NotificationOpenPayload.parseNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); @@ -560,23 +618,23 @@ void main() { userId: 1001, narrow: eg.topicNarrow(1, 'topic A'), ); - url = payload.buildAndroidNotificationUrl(); - check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + url = payload.buildNotificationUrl(); + check(NotificationOpenPayload.parseNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); }); - group('parseIosApnsPayload', () { + group('parseLegacyIosApnsPayload', () { test('smoke one-one DM', () { final userA = eg.user(userId: 1001); final userB = eg.user(userId: 1002); final account = eg.account( realmUrl: Uri.parse('http://chat.example'), user: userA); - final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]), + final payload = messageLegacyApnsPayload(eg.dmMessage(from: userB, to: [userA]), account: account); - check(NotificationOpenPayload.parseIosApnsPayload(payload)) + check(NotificationOpenPayload.parseLegacyIosApnsPayload(payload)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -590,9 +648,9 @@ void main() { final account = eg.account( realmUrl: Uri.parse('http://chat.example'), user: userA); - final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]), + final payload = messageLegacyApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]), account: account); - check(NotificationOpenPayload.parseIosApnsPayload(payload)) + check(NotificationOpenPayload.parseLegacyIosApnsPayload(payload)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -604,11 +662,11 @@ void main() { final account = eg.account( realmUrl: Uri.parse('http://chat.example'), user: userA); - final payload = messageApnsPayload(eg.streamMessage( + final payload = messageLegacyApnsPayload(eg.streamMessage( stream: eg.stream(streamId: 1), topic: 'topic A'), account: account); - check(NotificationOpenPayload.parseIosApnsPayload(payload)) + check(NotificationOpenPayload.parseLegacyIosApnsPayload(payload)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -617,13 +675,13 @@ void main() { }); }); - group('buildAndroidNotificationUrl', () { + group('buildNotificationUrl', () { test('smoke DM', () { final url = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildAndroidNotificationUrl(); + ).buildNotificationUrl(); check(url) ..scheme.equals('zulip') ..host.equals('notification') @@ -640,7 +698,7 @@ void main() { realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: eg.topicNarrow(1, 'topic A'), - ).buildAndroidNotificationUrl(); + ).buildNotificationUrl(); check(url) ..scheme.equals('zulip') ..host.equals('notification') @@ -654,7 +712,7 @@ void main() { }); }); - group('parseAndroidNotificationUrl', () { + group('parseNotificationUrl', () { test('smoke DM', () { final url = Uri( scheme: 'zulip', @@ -665,7 +723,7 @@ void main() { 'narrow_type': 'dm', 'all_recipient_ids': '1001,1002', }); - check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + check(NotificationOpenPayload.parseNotificationUrl(url)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -684,7 +742,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(NotificationOpenPayload.parseAndroidNotificationUrl(url)) + check(NotificationOpenPayload.parseNotificationUrl(url)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -743,7 +801,7 @@ void main() { }, ]; for (final params in testCases) { - check(() => NotificationOpenPayload.parseAndroidNotificationUrl(Uri( + check(() => NotificationOpenPayload.parseNotificationUrl(Uri( scheme: 'zulip', host: 'notification', queryParameters: params, @@ -769,7 +827,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + check(() => NotificationOpenPayload.parseNotificationUrl(url)) .throws(); }); @@ -784,7 +842,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url)) + check(() => NotificationOpenPayload.parseNotificationUrl(url)) .throws(); }); });