Files
iOS/Sources/App/Notifications/NotificationManager.swift
Zac West 175adfaaf4 Local push on macOS (#1570)
Refs #1382 and home-assistant/core#50750.

## Summary
Uses (and requires) the core-2021.6 local push handling to subscribe to notification calls and show notifications without going through the Apple Push Notification Service.

## Screenshots
| Light | Dark |
| -- | -- |
| <img width="712" alt="Screen Shot 2021-06-06 at 18 08 59" src="https://user-images.githubusercontent.com/74188/120946730-57a8d600-c6f2-11eb-8dde-b03b42a0a03a.png"> | <img width="712" alt="Screen Shot 2021-06-06 at 18 09 09" src="https://user-images.githubusercontent.com/74188/120946737-5d9eb700-c6f2-11eb-8563-8d9b8d8e075b.png"> |

## Link to pull request in Documentation repository
Documentation: home-assistant/companion.home-assistant#539

## Any other notes
- Only works on macOS for this first round. The iOS implementation will be the same manager, but needs extensions scaffolding that I have to put together separately.
- iOS will also likely have a setting to control this behavior, since it has potential battery implications; macOS doesn't have such concerns.
- Adds tests around the parsing of the raw notification payloads that uses the same tests introduced in home-assistant/mobile-apps-fcm-push#55.
- Shows the state of the local push connectivity in Notifications. With multi-server support, this will probably need to be broken down by server, or moved into the server-specific configurations.

This also sets us up to support encrypted notifications, since we're now able to handle the service call's data without any kind of remote manipulation. Unfortunately Apple declined the entitlement which makes this extremely easy, so we'll still need to handle the "commands are unencrypted" nonsense.
2021-06-07 19:22:35 -07:00

383 lines
15 KiB
Swift

import CallbackURLKit
import Communicator
import FirebaseMessaging
import Foundation
import PromiseKit
import Shared
import UserNotifications
import XCGLogger
class NotificationManager: NSObject, LocalPushManagerDelegate {
static var didUpdateComplicationsNotification: Notification.Name {
.init(rawValue: "didUpdateComplicationsNotification")
}
var localPushManager: LocalPushManager? {
didSet {
precondition(Current.isCatalyst)
}
}
func setupNotifications() {
UNUserNotificationCenter.current().delegate = self
if Current.isCatalyst {
localPushManager = with(LocalPushManager()) {
$0.delegate = self
}
}
}
func resetPushID() -> Promise<String> {
firstly {
Promise<Void> { seal in
Messaging.messaging().deleteToken(completion: seal.resolve)
}
}.then {
Promise<String> { seal in
Messaging.messaging().token(completion: seal.resolve)
}
}
}
func setupFirebase() {
Current.Log.verbose("Calling UIApplication.shared.registerForRemoteNotifications()")
UIApplication.shared.registerForRemoteNotifications()
Messaging.messaging().delegate = self
Messaging.messaging().isAutoInitEnabled = Current.settingsStore.privacy.messaging
}
func didFailToRegisterForRemoteNotifications(error: Error) {
Current.Log.error("failed to register for remote notifications: \(error)")
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
let apnsToken = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Current.Log.verbose("Successfully registered for push notifications! APNS token: \(apnsToken)")
Current.crashReporter.setUserProperty(value: apnsToken, name: "APNS Token")
var tokenType: MessagingAPNSTokenType = .prod
if Current.appConfiguration == .Debug {
tokenType = .sandbox
}
Messaging.messaging().setAPNSToken(deviceToken, type: tokenType)
}
func didReceiveRemoteNotification(
userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Messaging.messaging().appDidReceiveMessage(userInfo)
firstly {
handleRemoteNotification(userInfo: userInfo)
}.done(
completionHandler
)
}
func localPushManager(
_ manager: LocalPushManager,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) {
handleRemoteNotification(userInfo: userInfo).cauterize()
}
private func handleRemoteNotification(userInfo: [AnyHashable: Any]) -> Guarantee<UIBackgroundFetchResult> {
let (promise, seal) = Guarantee<UIBackgroundFetchResult>.pending()
Current.Log.verbose("remote notification: \(userInfo)")
if let userInfoDict = userInfo as? [String: Any],
let hadict = userInfoDict["homeassistant"] as? [String: Any], let command = hadict["command"] as? String {
switch command {
case "request_location_update":
guard Current.settingsStore.locationSources.pushNotifications else {
Current.Log.info("ignoring request, location source of notifications is disabled")
seal(.noData)
return promise
}
Current.Log.verbose("Received remote request to provide a location update")
Current.backgroundTask(withName: "push-location-request") { remaining in
Current.api.then(on: nil) { api in
api.GetAndSendLocation(trigger: .PushNotification, maximumBackgroundTime: remaining)
}
}.map {
UIBackgroundFetchResult.newData
}.recover { _ in
Guarantee<UIBackgroundFetchResult>.value(.failed)
}.done(seal)
case "clear_badge":
Current.Log.verbose("Setting badge to 0 as requested")
UIApplication.shared.applicationIconBadgeNumber = 0
seal(.newData)
case "clear_notification":
Current.Log.verbose("clearing notification for \(userInfo)")
let keys = ["tag", "collapseId"].compactMap { hadict[$0] as? String }
if !keys.isEmpty {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys)
}
seal(.newData)
case "update_complications":
firstly {
updateComplications()
}.map {
Current.Log.info("successfully updated complications from notification")
return UIBackgroundFetchResult.newData
}.recover { error in
Current.Log.error("failed to update complications from notification: \(error)")
return Guarantee<UIBackgroundFetchResult>.value(.failed)
}.done(seal)
default:
Current.Log.warning("Received unknown command via APNS! \(userInfo)")
seal(.noData)
}
} else {
seal(.noData)
}
return promise
}
func updateComplications() -> Promise<Void> {
Promise { seal in
Communicator.shared.transfer(.init(content: [:])) { result in
switch result {
case .success: seal.fulfill(())
case let .failure(error): seal.reject(error)
}
}
}.get {
NotificationCenter.default.post(name: Self.didUpdateComplicationsNotification, object: nil)
}
}
fileprivate func handleShortcutNotification(
_ shortcutName: String,
_ shortcutDict: [String: String]
) {
var inputParams: CallbackURLKit.Parameters = shortcutDict
inputParams["name"] = shortcutName
Current.Log.verbose("Sending params in shortcut \(inputParams)")
let eventName: String = "ios.shortcut_run"
let deviceDict: [String: String] = [
"sourceDevicePermanentID": Constants.PermanentID, "sourceDeviceName": UIDevice.current.name,
"sourceDeviceID": Current.settingsStore.deviceID,
]
var eventData: [String: Any] = ["name": shortcutName, "input": shortcutDict, "device": deviceDict]
var successHandler: CallbackURLKit.SuccessCallback?
if shortcutDict["ignore_result"] == nil {
successHandler = { params in
Current.Log.verbose("Received params from shortcut run \(String(describing: params))")
eventData["status"] = "success"
eventData["result"] = params?["result"]
Current.Log.verbose("Success, sending data \(eventData)")
Current.api.then(on: nil) { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}.catch { error -> Void in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
}
let failureHandler: CallbackURLKit.FailureCallback = { error in
eventData["status"] = "failure"
eventData["error"] = error.XCUErrorParameters
Current.api.then(on: nil) { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}.catch { error -> Void in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
let cancelHandler: CallbackURLKit.CancelCallback = {
eventData["status"] = "cancelled"
Current.api.then(on: nil) { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}.catch { error -> Void in
Current.Log.error("Received error from createEvent during shortcut run \(error)")
}
}
do {
try Manager.shared.perform(
action: "run-shortcut",
urlScheme: "shortcuts",
parameters: inputParams,
onSuccess: successHandler,
onFailure: failureHandler,
onCancel: cancelHandler
)
} catch let error as NSError {
Current.Log.error("Running shortcut failed \(error)")
eventData["status"] = "error"
eventData["error"] = error.localizedDescription
Current.api.then(on: nil) { api in
api.CreateEvent(eventType: eventName, eventData: eventData)
}.catch { error -> Void in
Current.Log.error("Received error from CallbackURLKit perform \(error)")
}
}
}
}
extension NotificationManager: UNUserNotificationCenterDelegate {
private func urlString(from response: UNNotificationResponse) -> String? {
let content = response.notification.request.content
let urlValue = ["url", "uri", "clickAction"].compactMap { content.userInfo[$0] }.first
if let action = content.userInfoActionConfigs.first(
where: { $0.identifier.lowercased() == response.actionIdentifier.lowercased() }
), let url = action.url {
// we only allow the action-specific one to override global if it's set
return url
} else if let openURLRaw = urlValue as? String {
// global url [string], always do it if we aren't picking a specific action
return openURLRaw
} else if let openURLDictionary = urlValue as? [String: String] {
// old-style, per-action url -- for before we could define actions in the notification dynamically
return openURLDictionary.compactMap { key, value -> String? in
if response.actionIdentifier == UNNotificationDefaultActionIdentifier,
key.lowercased() == NotificationCategory.FallbackActionIdentifier {
return value
} else if key.lowercased() == response.actionIdentifier.lowercased() {
return value
} else {
return nil
}
}.first
} else {
return nil
}
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
Messaging.messaging().appDidReceiveMessage(response.notification.request.content.userInfo)
guard response.actionIdentifier != UNNotificationDismissActionIdentifier else {
Current.Log.info("ignoring dismiss action for notification")
completionHandler()
return
}
let userInfo = response.notification.request.content.userInfo
Current.Log.verbose("User info in incoming notification \(userInfo) with response \(response)")
if let shortcutDict = userInfo["shortcut"] as? [String: String],
let shortcutName = shortcutDict["name"] {
handleShortcutNotification(shortcutName, shortcutDict)
}
if let url = urlString(from: response) {
Current.Log.info("launching URL \(url)")
Current.sceneManager.webViewWindowControllerPromise.done {
$0.open(from: .notification, urlString: url)
}
}
if let info = HomeAssistantAPI.PushActionInfo(response: response) {
Current.backgroundTask(withName: "handle-push-action") { _ in
Current.api.then(on: nil) { api in
api.handlePushAction(for: info)
}
}.ensure {
completionHandler()
}.catch { err -> Void in
Current.Log.error("Error when handling push action: \(err)")
}
} else {
completionHandler()
}
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo)
if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil,
UIApplication.shared.applicationState != .background {
completionHandler([])
return
}
var methods: UNNotificationPresentationOptions = [.alert, .badge, .sound]
if let presentationOptions = notification.request.content.userInfo["presentation_options"] as? [String] {
methods = []
if presentationOptions.contains("sound") || notification.request.content.sound != nil {
methods.insert(.sound)
}
if presentationOptions.contains("badge") {
methods.insert(.badge)
}
if presentationOptions.contains("alert") {
methods.insert(.alert)
}
}
return completionHandler(methods)
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
let view = NotificationSettingsViewController()
view.doneButton = true
Current.sceneManager.webViewWindowControllerPromise.done {
var rootViewController = $0.window.rootViewController
if let navigationController = rootViewController as? UINavigationController {
rootViewController = navigationController.viewControllers.first
}
rootViewController?.dismiss(animated: false, completion: {
let navController = UINavigationController(rootViewController: view)
rootViewController?.present(navController, animated: true, completion: nil)
})
}
}
}
extension NotificationManager: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
let loggableCurrent = Current.settingsStore.pushID ?? "(null)"
let loggableNew = fcmToken ?? "(null)"
Current.Log.info("Firebase registration token refreshed, new token: \(loggableNew)")
if loggableCurrent != loggableNew {
Current.Log.warning("FCM token has changed from \(loggableCurrent) to \(loggableNew)")
}
Current.crashReporter.setUserProperty(value: fcmToken, name: "FCM Token")
Current.settingsStore.pushID = fcmToken
Current.backgroundTask(withName: "notificationManager-didReceiveRegistrationToken") { _ in
Current.api.then(on: nil) { api in
api.UpdateRegistration()
}
}.cauterize()
}
}