Dactyloidae/mobile/ios/Extensions/NotificationService/NotificationService.swift
2026-06-26 21:04:09 -07:00

274 lines
11 KiB
Swift

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Shared
import Storage
import Sync
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var display: SyncDataDisplay?
var profile: ExtensionProfile?
// This is run when an APNS notification with `mutable-content` is received.
// If the app is backgrounded, then the alert notification is displayed.
// If the app is foregrounded, then the notification.userInfo is passed straight to
// AppDelegate.application(_:didReceiveRemoteNotification:completionHandler:)
// Once the notification is tapped, then the same userInfo is passed to the same method in the AppDelegate.
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
let userInfo = request.content.userInfo
guard let content = (request.content.mutableCopy() as? UNMutableNotificationContent) else {
return self.didFinish(PushMessage.accountVerified)
}
if self.profile == nil {
self.profile = ExtensionProfile(localName: "profile")
}
guard let profile = self.profile else {
self.didFinish(with: .noProfile)
return
}
let queue = profile.queue
let display = SyncDataDisplay(content: content, contentHandler: contentHandler, tabQueue: queue)
self.display = display
profile.syncDelegate = display
let handler = FxAPushMessageHandler(with: profile)
handler.handle(userInfo: userInfo).upon { res in
self.didFinish(res.successValue, with: res.failureValue as? PushMessageError)
}
}
func didFinish(_ what: PushMessage? = nil, with error: PushMessageError? = nil) {
profile?.shutdown()
guard let display = self.display else {
return
}
// We cannot use tabqueue after the profile has shutdown;
// however, we can't use weak references, because TabQueue isn't a class.
// Rather than changing tabQueue, we manually nil it out here.
display.tabQueue = nil
display.messageDelivered = false
display.displayNotification(what, with: error)
if !display.messageDelivered {
display.displayUnknownMessageNotification()
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
didFinish(with: .timeout)
}
}
class SyncDataDisplay {
var contentHandler: ((UNNotificationContent) -> Void)
var notificationContent: UNMutableNotificationContent
var sentTabs: [SentTab]
var tabQueue: TabQueue?
var messageDelivered: Bool = false
init(content: UNMutableNotificationContent, contentHandler: @escaping (UNNotificationContent) -> Void, tabQueue: TabQueue) {
self.contentHandler = contentHandler
self.notificationContent = content
self.sentTabs = []
self.tabQueue = tabQueue
}
func displayNotification(_ message: PushMessage? = nil, with error: PushMessageError? = nil) {
guard let message = message, error == nil else {
return displayUnknownMessageNotification()
}
switch message {
case .accountVerified:
displayAccountVerifiedNotification()
case .deviceConnected(let deviceName):
displayDeviceConnectedNotification(deviceName)
case .deviceDisconnected(let deviceName):
displayDeviceDisconnectedNotification(deviceName)
case .thisDeviceDisconnected:
displayThisDeviceDisconnectedNotification()
case .collectionChanged(let collections):
if collections.contains("clients") {
displaySentTabNotification()
} else {
displayUnknownMessageNotification()
}
default:
displayUnknownMessageNotification()
break
}
}
}
extension SyncDataDisplay {
func displayDeviceConnectedNotification(_ deviceName: String) {
presentNotification(title: Strings.FxAPush_DeviceConnected_title,
body: Strings.FxAPush_DeviceConnected_body,
bodyArg: deviceName)
}
func displayDeviceDisconnectedNotification(_ deviceName: String?) {
if let deviceName = deviceName {
presentNotification(title: Strings.FxAPush_DeviceDisconnected_title,
body: Strings.FxAPush_DeviceDisconnected_body,
bodyArg: deviceName)
} else {
// We should never see this branch
presentNotification(title: Strings.FxAPush_DeviceDisconnected_title,
body: Strings.FxAPush_DeviceDisconnected_UnknownDevice_body)
}
}
func displayThisDeviceDisconnectedNotification() {
presentNotification(title: Strings.FxAPush_DeviceDisconnected_ThisDevice_title,
body: Strings.FxAPush_DeviceDisconnected_ThisDevice_body)
}
func displayAccountVerifiedNotification() {
presentNotification(title: Strings.SentTab_NoTabArrivingNotification_title, body: Strings.SentTab_NoTabArrivingNotification_body)
}
func displayUnknownMessageNotification() {
// if, by any change we haven't dealt with the message, then perhaps we
// can recycle it as a sent tab message.
if sentTabs.count > 0 {
displaySentTabNotification()
} else {
presentNotification(title: Strings.SentTab_NoTabArrivingNotification_title, body: Strings.SentTab_NoTabArrivingNotification_body)
}
}
}
extension SyncDataDisplay {
func displaySentTabNotification() {
// We will need to be more precise about calling these SentTab alerts
// once we are a) detecting different types of notifications and b) adding actions.
// For now, we need to add them so we can handle zero-tab sent-tab-notifications.
notificationContent.categoryIdentifier = "org.mozilla.ios.SentTab.placeholder"
var userInfo = notificationContent.userInfo
// Add the tabs we've found to userInfo, so that the AppDelegate
// doesn't have to do it again.
let serializedTabs = sentTabs.flatMap { t -> NSDictionary? in
return [
"title": t.title,
"url": t.url.absoluteString,
"displayURL": t.url.absoluteDisplayExternalString,
"deviceName": t.deviceName as Any,
] as NSDictionary
}
func present(_ tabs: [NSDictionary]) {
if !tabs.isEmpty {
userInfo["sentTabs"] = tabs as NSArray
}
notificationContent.userInfo = userInfo
presentSentTabsNotification(tabs)
}
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { notifications in
// Let's deal with sent-tab-notifications
let sentTabNotifications = notifications.filter {
$0.request.content.categoryIdentifier == self.notificationContent.categoryIdentifier
}
// We can delete zero tab sent-tab-notifications
let emptyTabNotificationsIds = sentTabNotifications.filter {
$0.request.content.userInfo["sentTabs"] == nil
}.map { $0.request.identifier }
center.removeDeliveredNotifications(withIdentifiers: emptyTabNotificationsIds)
// The one we've just received (but not delivered) may not have any tabs in it either
// e.g. if the previous one consumed two tabs.
if serializedTabs.count == 0 {
// In that case, we try and recycle an existing notification (one that has a tab in it).
if let firstNonEmpty = sentTabNotifications.first(where: { $0.request.content.userInfo["sentTabs"] != nil }),
let previouslyDeliveredTabs = firstNonEmpty.request.content.userInfo["sentTabs"] as? [NSDictionary] {
center.removeDeliveredNotifications(withIdentifiers: [firstNonEmpty.request.identifier])
return present(previouslyDeliveredTabs)
}
}
// We have tabs in this notification, or we couldn't recycle an existing one that does.
present(serializedTabs)
}
}
func presentSentTabsNotification(_ tabs: [NSDictionary]) {
let title: String
let body: String
if tabs.count == 0 {
title = Strings.SentTab_NoTabArrivingNotification_title
body = Strings.SentTab_NoTabArrivingNotification_body
} else {
let deviceNames = Set(tabs.flatMap { $0["deviceName"] as? String })
if let deviceName = deviceNames.first, deviceNames.count == 1 {
title = String(format: Strings.SentTab_TabArrivingNotification_WithDevice_title, deviceName)
} else {
title = Strings.SentTab_TabArrivingNotification_NoDevice_title
}
if tabs.count == 1 {
// We give the fallback string as the url,
// because we have only just introduced "displayURL" as a key.
body = (tabs[0]["displayURL"] as? String) ??
(tabs[0]["url"] as! String)
} else if deviceNames.count == 0 {
body = Strings.SentTab_TabArrivingNotification_NoDevice_body
} else {
body = String(format: Strings.SentTab_TabArrivingNotification_WithDevice_body, AppInfo.displayName)
}
}
presentNotification(title: title, body: body)
}
func presentNotification(title: String, body: String, titleArg: String? = nil, bodyArg: String? = nil) {
func stringWithOptionalArg(_ s: String, _ a: String?) -> String {
if let a = a {
return String(format: s, a)
}
return s
}
notificationContent.title = stringWithOptionalArg(title, titleArg)
notificationContent.body = stringWithOptionalArg(body, bodyArg)
// This is the only place we call the contentHandler.
contentHandler(notificationContent)
// This is the only place we change messageDelivered. We can check if contentHandler hasn't be called because of
// our logic (rather than something funny with our environment, or iOS killing us).
messageDelivered = true
}
}
extension SyncDataDisplay: SyncDelegate {
func displaySentTab(for url: URL, title: String, from deviceName: String?) {
if url.isWebPage() {
sentTabs.append(SentTab(url: url, title: title, deviceName: deviceName))
let item = ShareItem(url: url.absoluteString, title: title, favicon: nil)
_ = tabQueue?.addToQueue(item)
}
}
}
struct SentTab {
let url: URL
let title: String
let deviceName: String?
}