diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 4627ea5c22..b152172ba5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -906,6 +906,7 @@ 42955C252F1A552A00E398E8 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1382F0FE50C00E20584 /* ToastView.swift */; }; 42955C262F1A552A00E398E8 /* ToastHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1402F0FE5B000E20584 /* ToastHostingController.swift */; }; 42955C272F1A552A00E398E8 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1372F0FE50C00E20584 /* Toast.swift */; }; + 42955C352F1E25FB00E398E8 /* CallKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42955C322F1E25FB00E398E8 /* CallKitManager.swift */; }; 4296C36D2B90DB640051B63C /* IntentActionAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */; }; 4296C36E2B90DB640051B63C /* PerformAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36C2B90DB630051B63C /* PerformAction.swift */; }; 4296C3762B91F0F50051B63C /* WidgetActionsAppIntentTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */; }; @@ -2551,6 +2552,7 @@ 429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewJavascriptCommandsTests.swift; sourceTree = ""; }; 429481EC2DA943E700A8B468 /* ListPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPicker.swift; sourceTree = ""; }; 429481EE2DA94B9900A8B468 /* ListPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPickerTests.swift; sourceTree = ""; }; + 42955C322F1E25FB00E398E8 /* CallKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitManager.swift; sourceTree = ""; }; 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntentActionAppEntity.swift; sourceTree = ""; }; 4296C36C2B90DB630051B63C /* PerformAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformAction.swift; sourceTree = ""; }; 4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetActionsAppIntentTimelineProvider.swift; sourceTree = ""; }; @@ -5323,6 +5325,14 @@ path = Audio; sourceTree = ""; }; + 42955C332F1E25FB00E398E8 /* CallKit */ = { + isa = PBXGroup; + children = ( + 42955C322F1E25FB00E398E8 /* CallKitManager.swift */, + ); + path = CallKit; + sourceTree = ""; + }; 4296C36A2B90DB630051B63C /* AppIntents */ = { isa = PBXGroup; children = ( @@ -7263,6 +7273,7 @@ D0EEF325214DF30D00D1D360 /* Notifications */ = { isa = PBXGroup; children = ( + 42955C332F1E25FB00E398E8 /* CallKit */, 420F53E22C4E61C1003C8415 /* LocalNotificationDispatcher.swift */, 425573EE2B589B0F00145217 /* NotificationIdentifier.swift */, 11ADF93D267D34A20040A7E3 /* NotificationCommands */, @@ -9944,6 +9955,7 @@ 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */, 420F53EA2C4E9D54003C8415 /* WidgetsKind.swift in Sources */, 11EE9B4924C5116F00404AF8 /* LegacyModelManager.swift in Sources */, + 42955C352F1E25FB00E398E8 /* CallKitManager.swift in Sources */, 46C62BA12D8A3799002C0001 /* SnapshottablePreviewConfigurations.swift in Sources */, 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, D0C3DC142134CD4E000C9EE1 /* CMMotion+StringExtensions.swift in Sources */, diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index ffb47c593d..7c0acf5e11 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -27,6 +27,11 @@ class NotificationManager: NSObject, LocalPushManagerDelegate { name: UIApplication.didBecomeActiveNotification, object: nil ) + + #if os(iOS) + // Set up CallKit delegate + CallKitManager.shared.delegate = self + #endif } func setupNotifications() { @@ -361,3 +366,32 @@ extension NotificationManager: MessagingDelegate { }.cauterize() } } + +#if os(iOS) +extension NotificationManager: CallKitManagerDelegate { + func callKitManager(_ manager: CallKitManager, didAnswerCallWithInfo info: [String: Any]) { + Current.Log.info("CallKit call answered, opening Assist") + + // Extract optional parameters from the call info + let pipelineId = info["pipeline_id"] as? String ?? "" + let autoStartRecording = info["auto_start_recording"] as? Bool ?? false + + // Determine which server to use + guard let server = Current.servers.all.first else { + Current.Log.error("No servers available to open Assist") + return + } + + // Open AssistView + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise).done { webViewController in + webViewController.webViewExternalMessageHandler.showAssist( + server: server, + pipeline: pipelineId, + autoStartRecording: autoStartRecording + ) + }.catch { error in + Current.Log.error("Failed to open Assist from CallKit: \(error)") + } + } +} +#endif diff --git a/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist.json b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist.json new file mode 100644 index 0000000000..af33b51c25 --- /dev/null +++ b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist.json @@ -0,0 +1,26 @@ +{ + "input": { + "title": "extra excluded title", + "message": "call_assist", + "registration_info": { + "app_id": "io.robbie.HomeAssistant.dev", + "os_version": "16.0", + "app_version": "2024.7" + } + }, + "rate_limit": false, + "headers": { + "apns-push-type": "background" + }, + "payload": { + "aps": { + "contentAvailable": true + }, + "homeassistant": { + "command": "call_assist", + "caller_name": "Home Assistant", + "pipeline_id": "", + "auto_start_recording": false + } + } +} diff --git a/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist_with_params.json b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist_with_params.json new file mode 100644 index 0000000000..c72b1feb0d --- /dev/null +++ b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/command_call_assist_with_params.json @@ -0,0 +1,26 @@ +{ + "input": { + "title": "extra excluded title", + "message": "call_assist_with_params", + "registration_info": { + "app_id": "io.robbie.HomeAssistant.dev", + "os_version": "16.0", + "app_version": "2024.7" + } + }, + "rate_limit": false, + "headers": { + "apns-push-type": "background" + }, + "payload": { + "aps": { + "contentAvailable": true + }, + "homeassistant": { + "command": "call_assist", + "caller_name": "Kitchen Motion", + "pipeline_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "auto_start_recording": true + } + } +} diff --git a/Sources/Shared/Notifications/CallKit/CallKitManager.swift b/Sources/Shared/Notifications/CallKit/CallKitManager.swift new file mode 100644 index 0000000000..bb08f041cc --- /dev/null +++ b/Sources/Shared/Notifications/CallKit/CallKitManager.swift @@ -0,0 +1,141 @@ +import AVFoundation +import CallKit +import Foundation +import PromiseKit + +public protocol CallKitManagerDelegate: AnyObject { + func callKitManager(_ manager: CallKitManager, didAnswerCallWithInfo info: [String: Any]) +} + +public class CallKitManager: NSObject { + public static let shared = CallKitManager() + + private let provider: CXProvider + private let callController = CXCallController() + public weak var delegate: CallKitManagerDelegate? + + // Thread-safe access to call state + private let queue = DispatchQueue(label: "com.homeassistant.callkit") + private var _activeCallInfo: [String: Any]? + private var _activeCallUUID: UUID? + + private var activeCallInfo: [String: Any]? { + get { queue.sync { _activeCallInfo } } + set { queue.sync { _activeCallInfo = newValue } } + } + + private var activeCallUUID: UUID? { + get { queue.sync { _activeCallUUID } } + set { queue.sync { _activeCallUUID = newValue } } + } + + // Atomically capture and clear call state + private func captureAndClearState() -> (info: [String: Any]?, uuid: UUID?) { + queue.sync { + let info = _activeCallInfo + let uuid = _activeCallUUID + _activeCallInfo = nil + _activeCallUUID = nil + return (info, uuid) + } + } + + // Atomically clear call state + private func clearState() { + queue.sync { + _activeCallInfo = nil + _activeCallUUID = nil + } + } + + override private init() { + let config = CXProviderConfiguration() + config.supportsVideo = false + config.maximumCallGroups = 1 + config.maximumCallsPerCallGroup = 1 + config.supportedHandleTypes = [.generic] + + self.provider = CXProvider(configuration: config) + + super.init() + + provider.setDelegate(self, queue: nil) + } + + public func reportIncomingCall(callerName: String, userInfo: [String: Any]) -> Promise { + Promise { seal in + let uuid = UUID() + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: callerName) + update.hasVideo = false + update.localizedCallerName = callerName + + activeCallInfo = userInfo + activeCallUUID = uuid + + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if let error { + Current.Log.error("Failed to report incoming call: \(error)") + seal.reject(error) + } else { + Current.Log.info("Successfully reported incoming call") + seal.fulfill(()) + } + } + } + } + + private func endCall(uuid: UUID) { + let endCallAction = CXEndCallAction(call: uuid) + let transaction = CXTransaction(action: endCallAction) + + callController.request(transaction) { error in + if let error { + Current.Log.error("Failed to end call: \(error)") + } else { + Current.Log.info("Successfully ended call") + } + } + } +} + +extension CallKitManager: CXProviderDelegate { + public func providerDidReset(_ provider: CXProvider) { + Current.Log.info("CallKit provider did reset") + clearState() + } + + public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + Current.Log.info("User answered call") + + // Atomically capture and clear call state + let (callInfo, callUUID) = captureAndClearState() + + // Mark the action as fulfilled + action.fulfill() + + // Notify delegate with captured state + if let callInfo { + delegate?.callKitManager(self, didAnswerCallWithInfo: callInfo) + } + + // End the call immediately since we just need to trigger opening Assist + if let callUUID { + endCall(uuid: callUUID) + } + } + + public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + Current.Log.info("Call ended") + action.fulfill() + clearState() + } + + public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + Current.Log.info("CallKit audio session activated") + } + + public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + Current.Log.info("CallKit audio session deactivated") + } +} diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 215ed06634..4804449bd8 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -22,6 +22,7 @@ public class NotificationCommandManager { register(command: "clear_notification", handler: HandlerClearNotification()) #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) + register(command: "call_assist", handler: HandlerCallAssist()) #endif #if os(iOS) || os(macOS) @@ -128,4 +129,20 @@ private struct HandlerUpdateWidgets: NotificationCommandHandler { return Promise.value(()) } } + +private struct HandlerCallAssist: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.verbose("CallKit assist triggered by notification command") + Current.clientEventStore.addEvent(ClientEvent( + text: "Notification command triggered CallKit assist", + type: .notification + )) + + // Extract caller name from payload, default to "Home Assistant" + let callerName = payload["caller_name"] as? String ?? "Home Assistant" + + // Report incoming call via CallKit + return CallKitManager.shared.reportIncomingCall(callerName: callerName, userInfo: payload) + } +} #endif