Skip to content
12 changes: 12 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2551,6 +2552,7 @@
429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewJavascriptCommandsTests.swift; sourceTree = "<group>"; };
429481EC2DA943E700A8B468 /* ListPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPicker.swift; sourceTree = "<group>"; };
429481EE2DA94B9900A8B468 /* ListPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPickerTests.swift; sourceTree = "<group>"; };
42955C322F1E25FB00E398E8 /* CallKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitManager.swift; sourceTree = "<group>"; };
4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntentActionAppEntity.swift; sourceTree = "<group>"; };
4296C36C2B90DB630051B63C /* PerformAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformAction.swift; sourceTree = "<group>"; };
4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetActionsAppIntentTimelineProvider.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5323,6 +5325,14 @@
path = Audio;
sourceTree = "<group>";
};
42955C332F1E25FB00E398E8 /* CallKit */ = {
isa = PBXGroup;
children = (
42955C322F1E25FB00E398E8 /* CallKitManager.swift */,
);
path = CallKit;
sourceTree = "<group>";
};
4296C36A2B90DB630051B63C /* AppIntents */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -7263,6 +7273,7 @@
D0EEF325214DF30D00D1D360 /* Notifications */ = {
isa = PBXGroup;
children = (
42955C332F1E25FB00E398E8 /* CallKit */,
420F53E22C4E61C1003C8415 /* LocalNotificationDispatcher.swift */,
425573EE2B589B0F00145217 /* NotificationIdentifier.swift */,
11ADF93D267D34A20040A7E3 /* NotificationCommands */,
Expand Down Expand Up @@ -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 */,
Expand Down
34 changes: 34 additions & 0 deletions Sources/App/Notifications/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
141 changes: 141 additions & 0 deletions Sources/Shared/Notifications/CallKit/CallKitManager.swift
Original file line number Diff line number Diff line change
@@ -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<Void> {
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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -128,4 +129,20 @@ private struct HandlerUpdateWidgets: NotificationCommandHandler {
return Promise.value(())
}
}

private struct HandlerCallAssist: NotificationCommandHandler {
func handle(_ payload: [String: Any]) -> Promise<Void> {
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
Loading