mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 13:26:27 -05:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> - Added button to retry local push reconnection - Improves reconnection logic - Added logs to debug in case of issues ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
547 lines
21 KiB
Swift
547 lines
21 KiB
Swift
import CallbackURLKit
|
|
import Communicator
|
|
import FirebaseMessaging
|
|
import Foundation
|
|
import PromiseKit
|
|
import Shared
|
|
import SwiftUI
|
|
import UserNotifications
|
|
import XCGLogger
|
|
|
|
class NotificationManager: NSObject, LocalPushManagerDelegate {
|
|
lazy var localPushManager: NotificationManagerLocalPushInterface = {
|
|
#if targetEnvironment(simulator)
|
|
return NotificationManagerLocalPushInterfaceDirect(delegate: self)
|
|
#else
|
|
if Current.isCatalyst {
|
|
return NotificationManagerLocalPushInterfaceDirect(delegate: self)
|
|
} else {
|
|
return NotificationManagerLocalPushInterfaceExtension()
|
|
}
|
|
#endif
|
|
}()
|
|
|
|
var commandManager = NotificationCommandManager()
|
|
private weak var cameraOverlayController: UIViewController?
|
|
|
|
override init() {
|
|
super.init()
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(didBecomeActive),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(showCameraFromNotification(_:)),
|
|
name: NotificationCommandManager.didReceiveShowCameraNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(hideCameraFromNotification),
|
|
name: NotificationCommandManager.didReceiveHideCameraNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
func setupNotifications() {
|
|
UNUserNotificationCenter.current().delegate = self
|
|
_ = localPushManager
|
|
}
|
|
|
|
@objc private func didBecomeActive() {
|
|
if Current.settingsStore.clearBadgeAutomatically {
|
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
|
}
|
|
localPushManager.scheduleAppOpenLocalPushRetries()
|
|
}
|
|
|
|
@objc private func showCameraFromNotification(_ notification: Notification) {
|
|
guard UIApplication.shared.applicationState == .active else {
|
|
scheduleShowCameraFallbackNotification(userInfo: notification.userInfo)
|
|
return
|
|
}
|
|
|
|
openCamera(from: notification.userInfo)
|
|
}
|
|
|
|
private func openCamera(from userInfo: [AnyHashable: Any]?) {
|
|
guard #available(iOS 16.0, *) else {
|
|
Current.Log.info("Ignoring show_camera push command because camera player requires iOS 16")
|
|
return
|
|
}
|
|
|
|
guard let entityId = cameraEntityId(from: userInfo) else {
|
|
Current.Log.error("Received show_camera push command without a valid camera entity_id")
|
|
return
|
|
}
|
|
|
|
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
|
|
.done { webViewController in
|
|
let view = CameraPlayerView(
|
|
server: self.cameraServer(from: userInfo, fallback: webViewController.server),
|
|
cameraEntityId: entityId
|
|
).embeddedInHostingController()
|
|
self.cameraOverlayController = view
|
|
view.modalPresentationStyle = .overFullScreen
|
|
webViewController.presentOverlayController(controller: view, animated: true)
|
|
}.catch { error in
|
|
Current.Log.error("Failed to show camera from push command: \(error)")
|
|
}
|
|
}
|
|
|
|
private func scheduleShowCameraFallbackNotification(userInfo: [AnyHashable: Any]?) {
|
|
guard let entityId = cameraEntityId(from: userInfo) else {
|
|
Current.Log.error("Received show_camera push command without a valid camera entity_id")
|
|
return
|
|
}
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.body = L10n.CameraPlayer.Notification.body
|
|
content.sound = .default
|
|
var fallbackUserInfo: [AnyHashable: Any] = [
|
|
"homeassistant": [
|
|
"command": "show_camera",
|
|
"entity_id": entityId,
|
|
],
|
|
]
|
|
if let webhookId = webhookId(from: userInfo) {
|
|
fallbackUserInfo["webhook_id"] = webhookId
|
|
}
|
|
content.userInfo = fallbackUserInfo
|
|
|
|
Current.userNotificationCenter.add(UNNotificationRequest(
|
|
identifier: "show_camera.\(entityId)",
|
|
content: content,
|
|
trigger: nil
|
|
)) { error in
|
|
if let error {
|
|
Current.Log.error("Failed to schedule show_camera fallback notification: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cameraEntityId(from userInfo: [AnyHashable: Any]?) -> String? {
|
|
guard let userInfo else { return nil }
|
|
|
|
if let entityId = userInfo["entity_id"] as? String, entityId.hasPrefix("camera.") {
|
|
return entityId
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [String: Any],
|
|
let entityId = homeassistant["entity_id"] as? String,
|
|
entityId.hasPrefix("camera.") {
|
|
return entityId
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any],
|
|
let entityId = homeassistant["entity_id"] as? String,
|
|
entityId.hasPrefix("camera.") {
|
|
return entityId
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func webhookId(from userInfo: [AnyHashable: Any]?) -> String? {
|
|
guard let userInfo else { return nil }
|
|
|
|
if let webhookId = userInfo["webhook_id"] as? String {
|
|
return webhookId
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [String: Any] {
|
|
return homeassistant["webhook_id"] as? String
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any] {
|
|
return homeassistant["webhook_id"] as? String
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func cameraServer(from userInfo: [AnyHashable: Any]?, fallback: Server) -> Server {
|
|
guard let webhookId = webhookId(from: userInfo),
|
|
let server = Current.servers.server(forWebhookID: webhookId) else {
|
|
return fallback
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
@objc private func hideCameraFromNotification() {
|
|
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
|
|
.done { webViewController in
|
|
guard let cameraOverlayController = self.cameraOverlayController,
|
|
webViewController.overlayedController === cameraOverlayController else {
|
|
Current.Log.info("Ignoring hide_camera push command because no camera is on display")
|
|
return
|
|
}
|
|
|
|
webViewController.dismissOverlayController(animated: true) { [weak self] in
|
|
self?.cameraOverlayController = nil
|
|
}
|
|
}.catch { error in
|
|
Current.Log.error("Failed to hide camera from push command: \(error)")
|
|
}
|
|
}
|
|
|
|
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> {
|
|
Current.Log.verbose("remote notification: \(userInfo)")
|
|
|
|
return commandManager.handle(userInfo).map {
|
|
UIBackgroundFetchResult.newData
|
|
}.recover { _ in
|
|
Guarantee<UIBackgroundFetchResult>.value(.failed)
|
|
}
|
|
}
|
|
|
|
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 = "ios.shortcut_run"
|
|
let deviceDict: [String: String] = [
|
|
"sourceDevicePermanentID": AppConstants.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)")
|
|
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.CreateEvent(eventType: eventName, eventData: eventData)
|
|
}).catch { error 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
|
|
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.CreateEvent(eventType: eventName, eventData: eventData)
|
|
}).catch { error in
|
|
Current.Log.error("Received error from createEvent during shortcut run \(error)")
|
|
}
|
|
}
|
|
|
|
let cancelHandler: CallbackURLKit.CancelCallback = {
|
|
eventData["status"] = "cancelled"
|
|
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.CreateEvent(eventType: eventName, eventData: eventData)
|
|
}).catch { error 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
|
|
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.CreateEvent(eventType: eventName, eventData: eventData)
|
|
}).catch { error 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 isShowCameraCommand(userInfo: userInfo), cameraEntityId(from: userInfo) != nil {
|
|
openCamera(from: userInfo)
|
|
completionHandler()
|
|
return
|
|
}
|
|
|
|
guard let server = Current.servers.server(for: response.notification.request.content) else {
|
|
Current.Log.info("ignoring push when unable to find server")
|
|
completionHandler()
|
|
return
|
|
}
|
|
|
|
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, server: server, urlString: url, isComingFromAppIntent: false)
|
|
}
|
|
}
|
|
|
|
if let info = HomeAssistantAPI.PushActionInfo(response: response) {
|
|
Current.backgroundTask(withName: BackgroundTask.handlePushAction.rawValue) { _ in
|
|
Current.api(for: server)?
|
|
.handlePushAction(for: info) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable)
|
|
}.ensure {
|
|
completionHandler()
|
|
}.catch { err in
|
|
Current.Log.error("Error when handling push action: \(err)")
|
|
}
|
|
} else {
|
|
completionHandler()
|
|
}
|
|
|
|
if response.notification.request.identifier == NotificationIdentifier.carPlayIntro.rawValue {
|
|
Current.Log.info("Launching CarPlay configuration screen")
|
|
Current.sceneManager.webViewWindowControllerPromise.done {
|
|
let carPlayView = CarPlayConfigurationView().embeddedInHostingController()
|
|
$0.present(carPlayView)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isShowCameraCommand(userInfo: [AnyHashable: Any]) -> Bool {
|
|
if userInfo["command"] as? String == "show_camera" {
|
|
return true
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [String: Any],
|
|
homeassistant["command"] as? String == "show_camera" {
|
|
return true
|
|
}
|
|
|
|
if let homeassistant = userInfo["homeassistant"] as? [AnyHashable: Any],
|
|
homeassistant["command"] as? String == "show_camera" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification,
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
) {
|
|
Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo)
|
|
|
|
// Handle commands (including Live Activities) for foreground notifications.
|
|
// didReceiveRemoteNotification handles background pushes via Firebase/APNs,
|
|
// but willPresent fires when the app is in the foreground. Without this,
|
|
// notifications received while the app is open would never trigger the
|
|
// Live Activity handler.
|
|
// If a command is recognized, suppress the notification banner so the user
|
|
// sees only the Live Activity (not a duplicate standard notification).
|
|
if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any],
|
|
(hadict["command"] as? String) != nil || (hadict["live_update"] as? Bool) == true {
|
|
commandManager.handle(notification.request.content.userInfo).done {
|
|
completionHandler([])
|
|
}.catch { error in
|
|
// Unknown command — fall through to normal banner presentation so the user isn't silently swallowed.
|
|
if case NotificationCommandManager.CommandError.unknownCommand = error {
|
|
completionHandler([.badge, .sound, .list, .banner])
|
|
} else {
|
|
completionHandler([])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil,
|
|
UIApplication.shared.applicationState != .background {
|
|
completionHandler([])
|
|
return
|
|
}
|
|
|
|
var methods: UNNotificationPresentationOptions = [.badge, .sound, .list, .banner]
|
|
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("list") {
|
|
methods.insert(.list)
|
|
}
|
|
if presentationOptions.contains("banner") {
|
|
methods.insert(.banner)
|
|
}
|
|
}
|
|
return completionHandler(methods)
|
|
}
|
|
|
|
public func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
openSettingsFor notification: UNNotification?
|
|
) {
|
|
let rootView = NavigationView {
|
|
NotificationSettingsView(showsDoneButton: true)
|
|
}
|
|
.navigationViewStyle(.stack)
|
|
let hostingController = rootView.embeddedInHostingController()
|
|
|
|
Current.sceneManager.webViewWindowControllerPromise.done {
|
|
var rootViewController = $0.window.rootViewController
|
|
if let navigationController = rootViewController as? UINavigationController {
|
|
rootViewController = navigationController.viewControllers.first
|
|
}
|
|
rootViewController?.dismiss(animated: false, completion: {
|
|
rootViewController?.present(hostingController, 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: BackgroundTask.notificationManagerDidReceiveRegistrationToken.rawValue) { _ in
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.updateRegistration()
|
|
})
|
|
}.cauterize()
|
|
}
|
|
}
|