mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-20 18:26:36 -05:00
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.
383 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|