Skip to content
Draft
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
7 changes: 7 additions & 0 deletions ios/NotificationService/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
Expand Down
107 changes: 102 additions & 5 deletions ios/NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import Flutter
import Foundation
import Intents
import UIKit
import UserNotifications
import os

/// See docs:
/// https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension
/// https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications
class NotificationService: UNNotificationServiceExtension {
private static let senderAvatarUrlKey = "sender_avatar_url"
private static let senderIdKey = "sender_id"
private static let senderNameKey = "sender_name"
private static let notificationUrlKey = "notification_url"

let logger = Logger()

var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private var hasDeliveredContent = false

/// See docs: https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension/didreceive(_:withcontenthandler:)
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
hasDeliveredContent = false
self.contentHandler = contentHandler
bestAttemptContent =
(request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content) // TODO(log)
deliver(request.content, with: contentHandler)
return
}

Expand All @@ -40,7 +50,7 @@ class NotificationService: UNNotificationServiceExtension {
libraryURI: "package:zulip/notifications/ios_service.dart"
)
if !started {
contentHandler(request.content) // TODO(log)
deliver(request.content, with: contentHandler)
return
}

Expand Down Expand Up @@ -70,12 +80,21 @@ class NotificationService: UNNotificationServiceExtension {
bestAttemptContent.sound = UNNotificationSound.default
}
bestAttemptContent.userInfo = improvedNotificationContent.userInfo as [AnyHashable: Any]
contentHandler(bestAttemptContent)

Task {
let content = await self.communicationNotificationContent(
from: bestAttemptContent,
userInfo: improvedNotificationContent.userInfo
)
self.deliver(content, with: contentHandler)
loopRunning = false
}

case .failure(let error): // TODO(log)
self.logger.debug(
"IosNotifFlutterApi.didReceivePushNotification failed: \(error.localizedDescription)")
contentHandler(bestAttemptContent)
self.deliver(bestAttemptContent, with: contentHandler)
loopRunning = false
}
}

Expand Down Expand Up @@ -124,7 +143,85 @@ class NotificationService: UNNotificationServiceExtension {
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent
{
contentHandler(bestAttemptContent) // TODO(log)
deliver(bestAttemptContent, with: contentHandler)
}
}

/// Delivers content at most once. The notification service extension can
/// race a network completion with serviceExtensionTimeWillExpire().
private func deliver(
_ content: UNNotificationContent,
with handler: @escaping (UNNotificationContent) -> Void
) {
guard !hasDeliveredContent else { return }
hasDeliveredContent = true
handler(content)
}

/// Converts a normal notification into an iOS Communication Notification.
/// Any failure returns the already-prepared normal notification.
private func communicationNotificationContent(
from content: UNMutableNotificationContent,
userInfo: [String: Any?]
) async -> UNNotificationContent {
guard
let avatarUrlString = userInfo[Self.senderAvatarUrlKey] as? String,
let avatarUrl = URL(string: avatarUrlString),
avatarUrl.scheme == "https",
let senderId = userInfo[Self.senderIdKey] as? String,
let senderName = userInfo[Self.senderNameKey] as? String
else {
return content
}

var request = URLRequest(url: avatarUrl)
request.timeoutInterval = 5

guard
let (imageData, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode),
imageData.count <= 2 * 1024 * 1024,
UIImage(data: imageData) != nil
else {
return content
}

let avatar = INImage(imageData: imageData)
let sender = INPerson(
personHandle: INPersonHandle(value: senderId, type: .unknown),
nameComponents: nil,
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: senderId
)

let conversationIdentifier = userInfo[Self.notificationUrlKey] as? String
let speakableGroupName =
content.title.isEmpty
? nil
: INSpeakableString(spokenPhrase: content.title)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: speakableGroupName,
conversationIdentifier: conversationIdentifier,
serviceName: "Zulip",
sender: sender,
attachments: nil
)

let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming

do {
try await interaction.donate()
return try content.updating(from: intent)
} catch {
logger.debug("Unable to create Communication Notification: \(error.localizedDescription)")
return content
}
}
}
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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 */; };
C7A9E9E80000000000000001 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C7A9E9E70000000000000001 /* Intents.framework */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -92,6 +93,7 @@
B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = "<group>"; };
B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = "<group>"; };
C7A9E9E70000000000000001 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -151,6 +153,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C7A9E9E80000000000000001 /* Intents.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -226,6 +229,7 @@
DE33BFABA662D9225C39B5E5 /* Frameworks */ = {
isa = PBXGroup;
children = (
C7A9E9E70000000000000001 /* Intents.framework */,
3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */,
4818026434A88F9DB6808745 /* Pods_Runner_RunnerUITests.framework */,
);
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
<string>By allowing camera access, you can take photos and send them in Zulip messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos from your library and send them in Zulip messages.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(ZULIP_APP_GROUP_IDENTIFIER)</string>
Expand Down
6 changes: 6 additions & 0 deletions lib/notifications/ios_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ class _IosNotifFlutterApiImpl extends IosNotifFlutterApi {
// which conversation to open.
// See NotificationOpenService (in lib/notifications/ios_service.dart).
NotificationOpenPayload.kIosNotificationUrlKey: notificationUrl.toString(),
// These values are consumed by the iOS Notification Service Extension
// to create a Communication Notification with the sender's avatar.
NotificationOpenPayload.kIosNotificationSenderAvatarUrlKey:
data.senderAvatarUrl.toString(),
NotificationOpenPayload.kIosNotificationSenderIdKey: data.senderId.toString(),
NotificationOpenPayload.kIosNotificationSenderNameKey: data.senderFullName,
});
}
}
6 changes: 6 additions & 0 deletions lib/notifications/open.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ class NotificationOpenPayload {
/// [NotificationOpenService] above).
static const kIosNotificationUrlKey = 'notification_url';

/// The sender metadata used by the iOS notification service extension to
/// create a Communication Notification with the sender's avatar.
static const kIosNotificationSenderAvatarUrlKey = 'sender_avatar_url';
static const kIosNotificationSenderIdKey = 'sender_id';
static const kIosNotificationSenderNameKey = 'sender_name';

/// Parses the iOS APNs payload and retrieves the information
/// required for navigation.
factory NotificationOpenPayload.parseIosApnsPayload(Map<Object?, Object?> payload) {
Expand Down
4 changes: 4 additions & 0 deletions test/notifications/ios_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ void main() {
..sound.equals(IosNotificationSound.systemDefault)
..userInfo.deepEquals({
NotificationOpenPayload.kIosNotificationUrlKey: expectedNotificationUrl.toString(),
NotificationOpenPayload.kIosNotificationSenderAvatarUrlKey:
data.senderAvatarUrl.toString(),
NotificationOpenPayload.kIosNotificationSenderIdKey: data.senderId.toString(),
NotificationOpenPayload.kIosNotificationSenderNameKey: data.senderFullName,
});
}

Expand Down
Loading