mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
309 lines
12 KiB
Swift
309 lines
12 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import UserNotifications
|
|
import SessionMessagingKit
|
|
import SignalUtilitiesKit
|
|
import SessionUtilitiesKit
|
|
|
|
class UserNotificationConfig {
|
|
|
|
class var allNotificationCategories: Set<UNNotificationCategory> {
|
|
let categories = AppNotificationCategory.allCases.map { notificationCategory($0) }
|
|
return Set(categories)
|
|
}
|
|
|
|
class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] {
|
|
return category.actions.map { notificationAction($0) }
|
|
}
|
|
|
|
class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory {
|
|
return UNNotificationCategory(
|
|
identifier: category.identifier,
|
|
actions: notificationActions(for: category),
|
|
intentIdentifiers: [],
|
|
options: []
|
|
)
|
|
}
|
|
|
|
class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction {
|
|
switch action {
|
|
case .markAsRead:
|
|
return UNNotificationAction(
|
|
identifier: action.identifier,
|
|
title: "messageMarkRead".localized(),
|
|
options: []
|
|
)
|
|
|
|
case .reply:
|
|
return UNTextInputNotificationAction(
|
|
identifier: action.identifier,
|
|
title: "reply".localized(),
|
|
options: [],
|
|
textInputButtonTitle: "send".localized(),
|
|
textInputPlaceholder: ""
|
|
)
|
|
}
|
|
}
|
|
|
|
class func action(identifier: String) -> AppNotificationAction? {
|
|
return AppNotificationAction.allCases.first { notificationAction($0).identifier == identifier }
|
|
}
|
|
}
|
|
|
|
class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate {
|
|
private let notificationCenter: UNUserNotificationCenter
|
|
private var notifications: Atomic<[String: UNNotificationRequest]> = Atomic([:])
|
|
|
|
override init() {
|
|
self.notificationCenter = UNUserNotificationCenter.current()
|
|
|
|
super.init()
|
|
|
|
SwiftSingletons.register(self)
|
|
}
|
|
}
|
|
|
|
extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
|
func registerNotificationSettings() -> AnyPublisher<Void, Never> {
|
|
return Deferred {
|
|
Future { [weak self] resolver in
|
|
self?.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in
|
|
self?.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories)
|
|
|
|
if granted {}
|
|
else if let error: Error = error {
|
|
Log.error("[UserNotificationPresenterAdaptee] Failed with error: \(error)")
|
|
}
|
|
else {
|
|
Log.error("[UserNotificationPresenterAdaptee] Failed without error.")
|
|
}
|
|
|
|
// Note that the promise is fulfilled regardless of if notification permssions were
|
|
// granted. This promise only indicates that the user has responded, so we can
|
|
// proceed with requesting push tokens and complete registration.
|
|
resolver(Result.success(()))
|
|
}
|
|
}
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
|
|
func notify(
|
|
category: AppNotificationCategory,
|
|
title: String?,
|
|
body: String,
|
|
userInfo: [AnyHashable: Any],
|
|
previewType: Preferences.NotificationPreviewType,
|
|
sound: Preferences.Sound?,
|
|
threadVariant: SessionThread.Variant,
|
|
threadName: String,
|
|
applicationState: UIApplication.State,
|
|
replacingIdentifier: String?
|
|
) {
|
|
let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String)
|
|
let content = UNMutableNotificationContent()
|
|
content.categoryIdentifier = category.identifier
|
|
content.userInfo = userInfo
|
|
content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier)
|
|
|
|
let shouldGroupNotification: Bool = (
|
|
threadVariant == .community &&
|
|
replacingIdentifier == threadIdentifier
|
|
)
|
|
if let sound = sound, sound != .none {
|
|
content.sound = sound.notificationSound(isQuiet: (applicationState == .active))
|
|
}
|
|
|
|
let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString)
|
|
let isReplacingNotification: Bool = (notifications.wrappedValue[notificationIdentifier] != nil)
|
|
let shouldPresentNotification: Bool = shouldPresentNotification(
|
|
category: category,
|
|
applicationState: applicationState,
|
|
frontMostViewController: SessionApp.currentlyOpenConversationViewController.wrappedValue,
|
|
userInfo: userInfo
|
|
)
|
|
var trigger: UNNotificationTrigger?
|
|
|
|
if shouldPresentNotification {
|
|
if let displayableTitle = title?.filteredForDisplay {
|
|
content.title = displayableTitle
|
|
}
|
|
|
|
content.body = body.filteredForDisplay
|
|
|
|
if shouldGroupNotification {
|
|
trigger = UNTimeIntervalNotificationTrigger(
|
|
timeInterval: Notifications.delayForGroupedNotifications,
|
|
repeats: false
|
|
)
|
|
|
|
let numberExistingNotifications: Int? = notifications.wrappedValue[notificationIdentifier]?
|
|
.content
|
|
.userInfo[AppNotificationUserInfoKey.threadNotificationCounter]
|
|
.asType(Int.self)
|
|
var numberOfNotifications: Int = (numberExistingNotifications ?? 1)
|
|
|
|
if numberExistingNotifications != nil {
|
|
numberOfNotifications += 1 // Add one for the current notification
|
|
|
|
content.title = (previewType == .noNameNoPreview ?
|
|
content.title :
|
|
threadName
|
|
)
|
|
content.body = "messageNewYouveGot"
|
|
.putNumber(numberOfNotifications)
|
|
.localized()
|
|
}
|
|
|
|
content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications
|
|
}
|
|
}
|
|
else {
|
|
// Play sound and vibrate, but without a `body` no banner will show.
|
|
Log.debug("[UserNotificationPresenterAdaptee] Supressing notification body")
|
|
}
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: notificationIdentifier,
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
Log.debug("[UserNotificationPresenterAdaptee] Presenting notification with identifier: \(notificationIdentifier)")
|
|
|
|
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
|
|
|
|
notificationCenter.add(request)
|
|
notifications.mutate { $0[notificationIdentifier] = request }
|
|
}
|
|
|
|
func cancelNotifications(identifiers: [String]) {
|
|
notifications.mutate { notifications in
|
|
identifiers.forEach { notifications.removeValue(forKey: $0) }
|
|
}
|
|
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
|
}
|
|
|
|
func cancelNotification(_ notification: UNNotificationRequest) {
|
|
cancelNotifications(identifiers: [notification.identifier])
|
|
}
|
|
|
|
func cancelNotifications(threadId: String) {
|
|
let notificationsIdsToCancel: [String] = notifications.wrappedValue
|
|
.values
|
|
.compactMap { notification in
|
|
guard
|
|
let notificationThreadId: String = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String,
|
|
notificationThreadId == threadId
|
|
else { return nil }
|
|
|
|
return notification.identifier
|
|
}
|
|
|
|
cancelNotifications(identifiers: notificationsIdsToCancel)
|
|
}
|
|
|
|
func clearAllNotifications() {
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
notificationCenter.removeAllDeliveredNotifications()
|
|
}
|
|
|
|
func shouldPresentNotification(
|
|
category: AppNotificationCategory,
|
|
applicationState: UIApplication.State,
|
|
frontMostViewController: UIViewController?,
|
|
userInfo: [AnyHashable: Any]
|
|
) -> Bool {
|
|
guard applicationState == .active else { return true }
|
|
|
|
guard category == .incomingMessage || category == .errorMessage else {
|
|
return true
|
|
}
|
|
|
|
guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
Log.error("[UserNotificationPresenterAdaptee] threadId was unexpectedly nil")
|
|
return true
|
|
}
|
|
|
|
guard let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC else {
|
|
return true
|
|
}
|
|
|
|
/// Show notifications for any **other** threads
|
|
return (conversationViewController.viewModel.threadData.threadId != notificationThreadId)
|
|
}
|
|
}
|
|
|
|
@objc(OWSUserNotificationActionHandler)
|
|
public class UserNotificationActionHandler: NSObject {
|
|
|
|
var actionHandler: NotificationActionHandler {
|
|
return NotificationActionHandler.shared
|
|
}
|
|
|
|
func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void, using dependencies: Dependencies) {
|
|
Log.assertOnMainThread()
|
|
handleNotificationResponse(response, using: dependencies)
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.receive(on: DispatchQueue.main)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure(let error):
|
|
completionHandler()
|
|
Log.error("Failed to handle notification response: \(error)")
|
|
}
|
|
},
|
|
receiveValue: { _ in completionHandler() }
|
|
)
|
|
}
|
|
|
|
func handleNotificationResponse( _ response: UNNotificationResponse, using dependencies: Dependencies) -> AnyPublisher<Void, Error> {
|
|
Log.assertOnMainThread()
|
|
assert(Singleton.appReadiness.isAppReady)
|
|
|
|
let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo
|
|
let applicationState: UIApplication.State = UIApplication.shared.applicationState
|
|
|
|
switch response.actionIdentifier {
|
|
case UNNotificationDefaultActionIdentifier:
|
|
Log.debug("Notification response: default action")
|
|
return actionHandler.showThread(userInfo: userInfo, using: dependencies)
|
|
.setFailureType(to: Error.self)
|
|
.eraseToAnyPublisher()
|
|
|
|
case UNNotificationDismissActionIdentifier:
|
|
// TODO - mark as read?
|
|
Log.debug("Notification response: dismissed notification")
|
|
return Just(())
|
|
.setFailureType(to: Error.self)
|
|
.eraseToAnyPublisher()
|
|
|
|
default:
|
|
// proceed
|
|
break
|
|
}
|
|
|
|
guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else {
|
|
return Fail(error: NotificationError.failDebug("unable to find action for actionIdentifier: \(response.actionIdentifier)"))
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
switch action {
|
|
case .markAsRead:
|
|
return actionHandler.markAsRead(userInfo: userInfo)
|
|
|
|
case .reply:
|
|
guard let textInputResponse = response as? UNTextInputNotificationResponse else {
|
|
return Fail(error: NotificationError.failDebug("response had unexpected type: \(response)"))
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText, applicationState: applicationState)
|
|
}
|
|
}
|
|
}
|