Files
iOS/Sources/App/AppDelegate.swift
Bruno Pantaleão Gonçalves 7cbdcb762f Remove legacy actions (#4584)
<!-- 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 -->

## 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. -->

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 15:20:24 +02:00

491 lines
17 KiB
Swift

import Alamofire
import CallbackURLKit
import Communicator
#if DEBUG
import DebugSwift
#endif
import FirebaseCore
import FirebaseMessaging
import Intents
import KeychainAccess
import MBProgressHUD
import ObjectMapper
import PromiseKit
import RealmSwift
import SafariServices
import Shared
import UIKit
import WidgetKit
import XCGLogger
let keychain = AppConstants.Keychain
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
private extension UIApplication {
var typedDelegate: AppDelegate {
// swiftlint:disable:next force_cast
delegate as! AppDelegate
}
}
extension AppEnvironment {
var sceneManager: SceneManager {
UIApplication.shared.typedDelegate.sceneManager
}
var notificationManager: NotificationManager {
UIApplication.shared.typedDelegate.notificationManager
}
}
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
let sceneManager = SceneManager()
private let lifecycleManager = LifecycleManager()
let notificationManager = NotificationManager()
#if DEBUG
private let debugSwift = DebugSwift()
#endif
private var zoneManager: ZoneManager?
private var titleSubscription: MenuManagerTitleSubscription? {
didSet {
if oldValue != titleSubscription {
oldValue?.cancel()
}
}
}
private var shouldRefreshTitleSubscription = false
private var watchCommunicatorService: WatchCommunicatorService?
func application(
_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
MaterialDesignIcons.register()
guard !Current.isRunningTests else {
return true
}
setDefaults()
// swiftlint:disable prohibit_environment_assignment
Current.backgroundTask = ApplicationBackgroundTaskRunner()
// Initialize UIApplication wrapper for shared framework
Current.application = {
UIApplication.shared
}
Current.isBackgroundRequestsImmediate = { [lifecycleManager] in
if Current.isCatalyst {
return false
} else {
return lifecycleManager.isActive
}
}
#if targetEnvironment(simulator)
Current.tags = SimulatorTagManager()
#else
Current.tags = iOSTagManager()
#endif
// swiftlint:enable prohibit_environment_assignment
notificationManager.setupNotifications()
setupLiveActivityReattachment()
setupFirebase()
setupModels()
setupLocalization()
setupMenus()
let launchingForLocation = launchOptions?[.location] != nil
let event = ClientEvent(
text: "Application Starting" + (launchingForLocation ? " due to location change" : ""),
type: .unknown
)
Current.clientEventStore.addEvent(event)
zoneManager = ZoneManager()
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
setupWatchCommunicator()
setupUIApplicationShortcutItems()
migrateIfNeeded()
return true
}
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if NSClassFromString("XCTest") != nil {
return true
}
lifecycleManager.didFinishLaunching()
setupDebugSwift()
checkForUpdate()
checkForAlerts()
return true
}
private func setupDebugSwift() {
#if DEBUG
debugSwift.setup()
debugSwift.show()
#endif
}
override func buildMenu(with builder: UIMenuBuilder) {
if builder.system == .main {
let manager = MenuManager(builder: builder)
manager.update()
#if targetEnvironment(macCatalyst)
titleSubscription = manager.subscribeStatusItemTitle(
existing: shouldRefreshTitleSubscription ? nil : titleSubscription,
update: Current.macBridge.configureStatusItem(title:)
)
shouldRefreshTitleSubscription = false
#endif
}
}
@objc func openAbout() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .about)
}
@objc func openMenuUrl(_ command: AnyObject) {
guard let command = command as? UICommand, let url = MenuManager.url(from: command) else {
return
}
let delegate: Guarantee<WebViewSceneDelegate> = sceneManager.scene(for: .init(activity: .webView))
delegate.done {
$0.urlHandler?.handle(url: url)
}
}
@objc func openPreferences() {
precondition(Current.sceneManager.supportsMultipleScenes)
sceneManager.activateAnyScene(for: .settings)
}
@objc func openHelp() {
openURLInBrowser(
URL(string: "https://companion.home-assistant.io")!,
nil
)
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication {
return SceneActivity.carPlay.configuration
} else {
let activity = options.userActivities
.compactMap { SceneActivity(activityIdentifier: $0.activityType) }
.first ?? .webView
return activity.configuration
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
notificationManager.didFailToRegisterForRemoteNotifications(error: error)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
notificationManager.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
notificationManager.didReceiveRemoteNotification(userInfo: userInfo, fetchCompletionHandler: completionHandler)
}
func application(
_ application: UIApplication,
performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Current.clientEventStore.addEvent(ClientEvent(text: "Background fetch activated", type: .backgroundOperation))
Current.backgroundTask(withName: BackgroundTask.backgroundFetch.rawValue) { remaining in
let updatePromise: Promise<Void>
if Current.settingsStore.isLocationEnabled(for: UIApplication.shared.applicationState),
Current.settingsStore.locationSources.backgroundFetch {
updatePromise = firstly {
Current.location.oneShotLocation(.BackgroundFetch, remaining)
}.then { location in
when(fulfilled: Current.apis.map {
$0.SubmitLocation(updateType: .BackgroundFetch, location: location, zone: nil)
})
}.asVoid()
} else {
updatePromise = when(fulfilled: Current.apis.map {
$0.UpdateSensors(trigger: .BackgroundFetch, location: nil)
})
}
return updatePromise
}.done {
completionHandler(.newData)
}.catch { error in
Current.Log.error("Error when attempting to update data during background fetch: \(error)")
completionHandler(.failed)
}
}
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if WebhookManager.isManager(forSessionIdentifier: identifier) {
Current.Log.info("starting webhook handler for \(identifier)")
Current.webhooks.handleBackground(for: identifier, completionHandler: completionHandler)
} else {
Current.Log.error("couldn't find appropriate session for for \(identifier)")
completionHandler()
}
}
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
IntentHandlerFactory.handler(for: intent)
}
// MARK: - Private helpers
@objc func checkForUpdate(_ sender: AnyObject? = nil) {
guard Current.updater.isSupported else { return }
let dueToUserInteraction = sender != nil
Current.updater.check(dueToUserInteraction: dueToUserInteraction).done { [sceneManager] update in
let alert = UIAlertController(
title: L10n.Updater.UpdateAvailable.title,
message: update.body,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(
title: L10n.Updater.UpdateAvailable.open(update.name),
style: .default,
handler: { _ in
URLOpener.shared.open(update.htmlUrl, options: [:], completionHandler: nil)
}
))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}.catch { [sceneManager] error in
Current.Log.error("check error: \(error)")
if dueToUserInteraction {
let alert = UIAlertController(
title: L10n.Updater.NoUpdatesAvailable.title,
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert, animated: true, completion: nil)
}
}
}
}
private func checkForAlerts() {
firstly {
Current.serverAlerter.check(dueToUserInteraction: false)
}.done { [sceneManager] alert in
sceneManager.webViewWindowControllerPromise.done { controller in
controller.show(alert: alert)
}
}.catch { error in
Current.Log.error("check error: \(error)")
}
showNotificationCategoryAlertIfNeeded()
}
private func showNotificationCategoryAlertIfNeeded() {
guard Current.realm().objects(NotificationCategory.self).isEmpty == false else {
return
}
let userDefaults = UserDefaults.standard
let seenKey = "category-deprecation-3-" + Current.clientVersion().description
guard !userDefaults.bool(forKey: seenKey) else {
return
}
when(fulfilled: Current.apis.compactMap { $0.connection.caches.user.once().promise })
.done { [sceneManager] users in
guard users.contains(where: \.isAdmin) else {
Current.Log.info("not showing because not an admin anywhere")
return
}
let alert = UIAlertController(
title: L10n.Alerts.Deprecations.NotificationCategory.title,
message: L10n.Alerts.Deprecations.NotificationCategory.message("iOS-2022.4"),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.Nfc.List.learnMore, style: .default, handler: { _ in
userDefaults.set(true, forKey: seenKey)
openURLInBrowser(
URL(string: "https://companion.home-assistant.io/app/ios/actionable-notifications")!,
nil
)
}))
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: { _ in
userDefaults.set(true, forKey: seenKey)
}))
sceneManager.webViewWindowControllerPromise.done {
$0.present(alert)
}
}.catch { error in
Current.Log.error("couldn't check for if user: \(error)")
}
}
private func setupWatchCommunicator() {
watchCommunicatorService = WatchCommunicatorService()
watchCommunicatorService?.setup()
}
func setupLocalization() {
Current.localized.add(stringProvider: { request in
if prefs.bool(forKey: "showTranslationKeys") {
return request.key
} else {
return nil
}
})
}
private func setupLiveActivityReattachment() {
#if os(iOS) && !targetEnvironment(macCatalyst)
if #available(iOS 17.2, *) {
// Pre-warm the registry on the main thread before spawning background Tasks.
// This avoids a lazy-init race if a push notification handler accesses it
// concurrently from a background thread.
guard let registry = Current.liveActivityRegistry else { return }
Task {
// Re-attach observation tasks (push token + lifecycle) to any Live Activities
// that survived the previous process termination. Must run before the first
// notification handler fires so no push token updates are missed.
await registry.reattach()
}
// Begin observing the push-to-start token stream on a separate Task.
// The stream is infinite; this Task is kept alive for the app's lifetime.
Task {
await registry.startObservingPushToStartToken()
}
// Observe activities that ActivityKit starts directly from APNs push-to-start.
// The stream is infinite; this Task is kept alive for the app's lifetime.
Task {
await registry.startObservingRemoteActivityStarts()
}
}
#endif
}
private func setupFirebase() {
let optionsFile: String = {
switch Current.appConfiguration {
case .beta: return "GoogleService-Info-Beta"
case .debug, .fastlaneSnapshot: return "GoogleService-Info-Debug"
case .release: return "GoogleService-Info-Release"
}
}()
if let optionsPath = Bundle.main.path(forResource: optionsFile, ofType: "plist"),
let options = FirebaseOptions(contentsOfFile: optionsPath) {
FirebaseApp.configure(options: options)
} else {
fatalError("no firebase config found")
}
notificationManager.setupFirebase()
}
private func setupModels() {
// Force Realm migration to happen now
_ = Realm.live()
NotificationCategory.setupObserver()
}
private func setupMenus() {
NotificationCenter.default.addObserver(
self,
selector: #selector(menuRelatedSettingDidChange(_:)),
name: SettingsStore.menuRelatedSettingDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(apiDidConnect(_:)),
name: HomeAssistantAPI.didConnectNotification,
object: nil
)
}
@objc private func menuRelatedSettingDidChange(_ note: Notification) {
UIMenuSystem.main.setNeedsRebuild()
}
@objc private func apiDidConnect(_ note: Notification) {
// When API reconnects, rebuild the menu to refresh the status item title subscription
// Force refresh by setting the flag that will cause the subscription to be recreated
#if targetEnvironment(macCatalyst)
shouldRefreshTitleSubscription = true
#endif
UIMenuSystem.main.setNeedsRebuild()
}
private func setupUIApplicationShortcutItems() {
AppIconShortcutItemsUpdater.update()
}
private func migrateIfNeeded() {
resetLocalPush()
}
/// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it
private func resetLocalPush() {
if !Current.settingsStore.migratedOptInLocalPush {
for server in Current.servers.all {
server.update { info in
info.connection.isLocalPushEnabled = false
}
}
Current.settingsStore.migratedOptInLocalPush = true
Current.Log.info("Reset local push for all servers due to migration")
} else {
Current.Log.info("No need to reset local push, migration already done")
}
}
// swiftlint:disable:next file_length
}