diff --git a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift index 33fecdd6f..4bb0a6e0b 100644 --- a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift +++ b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift @@ -8,17 +8,29 @@ import PushKit /// Handles push notifications for CallKit integration. open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, ObservableObject { + /// Represents the keys that the Payload dictionary + public enum PayloadKey: String { + case stream + case callCid = "call_cid" + case displayName = "call_display_name" + case createdByName = "created_by_display_name" + case createdById = "created_by_id" + } + /// Represents the content of a VoIP push notification. - fileprivate struct StreamVoIPPushNotificationContent { + public struct Content { var cid: String var localizedCallerName: String var callerId: String - init(from payload: PKPushPayload, defaultCallText: String) { - let streamDict = payload.dictionaryPayload["stream"] as? [String: Any] - cid = streamDict?["call_cid"] as? String ?? "unknown" - localizedCallerName = streamDict?["created_by_display_name"] as? String ?? defaultCallText - callerId = streamDict?["created_by_id"] as? String ?? defaultCallText + public init( + cid: String, + localizedCallerName: String, + callerId: String + ) { + self.cid = cid + self.localizedCallerName = localizedCallerName + self.callerId = callerId } } @@ -82,10 +94,8 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs ) { guard type == .voIP else { return } - let content = StreamVoIPPushNotificationContent( - from: payload, - defaultCallText: defaultCallText - ) + let content = decodePayload(payload) + log .debug( "Received VoIP push notification with cid:\(content.cid) callerId:\(content.callerId) callerName:\(content.localizedCallerName)." @@ -103,6 +113,45 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs } ) } + + /// Decodes push notification Payload to a type that the CallKit implementation can use. + open func decodePayload( + _ payload: PKPushPayload + ) -> Content { + func string(from: [String: Any], key: PayloadKey, fallback: String) -> String { + from[key.rawValue] as? String ?? fallback + } + + guard + let streamDict = payload.dictionaryPayload[PayloadKey.stream.rawValue] as? [String: Any] + else { + return .init( + cid: "unknown", + localizedCallerName: defaultCallText, + callerId: defaultCallText + ) + } + + let cid = string(from: streamDict, key: .callCid, fallback: "unknown") + + let displayName = string(from: streamDict, key: .displayName, fallback: "") + /// If no displayName ("display_name", "name", "title") was set, we default to the creator's name. + let localizedCallerName = displayName.isEmpty + ? string(from: streamDict, key: .createdByName, fallback: defaultCallText) + : displayName + + let callerId = string( + from: streamDict, + key: .createdById, + fallback: defaultCallText + ) + + return .init( + cid: cid, + localizedCallerName: localizedCallerName, + callerId: callerId + ) + } } extension CallKitPushNotificationAdapter: InjectionKey { diff --git a/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift b/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift index 29dbc3c52..77f75b19e 100644 --- a/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift +++ b/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift @@ -37,7 +37,7 @@ final class CallKitPushNotificationAdapterTests: XCTestCase { func test_unregister_registryWasConfiguredCorrectly() { subject.register() - + subject.unregister() XCTAssertNil(subject.registry.delegate) @@ -68,45 +68,28 @@ final class CallKitPushNotificationAdapterTests: XCTestCase { // MARK: - pushRegistry(_:didReceiveIncomingPushWith:for:completion:) func test_pushRegistryDidReceiveIncomingPush_typeIsVoIP_reportIncomingCallWasCalledAsExpected() { - let pushPayload = MockPKPushPayload() - pushPayload.stubType = .voIP - pushPayload.stubDictionaryPayload = [ - "stream": [ - "call_cid": "123", - "created_by_display_name": "TestUser", - "created_by_id": "test_user" - ] - ] - - let completionWasCalledExpectation = expectation(description: "Completion was called.") - subject.pushRegistry( - subject.registry, - didReceiveIncomingPushWith: pushPayload, - for: pushPayload.type, - completion: { completionWasCalledExpectation.fulfill() } + assertDidReceivePushNotification( + .init( + cid: "123", + localizedCallerName: "TestUser", + callerId: "test_user" + ) ) - - XCTAssertEqual(callKitService.reportIncomingCallWasCalled?.cid, "123") - XCTAssertEqual(callKitService.reportIncomingCallWasCalled?.callerName, "TestUser") - XCTAssertEqual(callKitService.reportIncomingCallWasCalled?.callerId, "test_user") - callKitService.reportIncomingCallWasCalled?.completion(nil) - - wait(for: [completionWasCalledExpectation], timeout: defaultTimeout) } - func test_pushRegistryDidReceiveIncomingPush_typeIsNotVoIP_reportIncomingCallWasNotCalled() { - let pushPayload = MockPKPushPayload() - pushPayload.stubType = .fileProvider - pushPayload.stubDictionaryPayload = [:] - - subject.pushRegistry( - subject.registry, - didReceiveIncomingPushWith: pushPayload, - for: pushPayload.type, - completion: {} + func test_pushRegistryDidReceiveIncomingPush_typeIsVoIPWithDisplayNameAndCallerName_reportIncomingCallWasCalledAsExpected() { + assertDidReceivePushNotification( + .init( + cid: "123", + localizedCallerName: "TestUser", + callerId: "test_user" + ), + displayName: "Stream Group Call" ) + } - XCTAssertNil(callKitService.reportIncomingCallWasCalled) + func test_pushRegistryDidReceiveIncomingPush_typeIsNotVoIP_reportIncomingCallWasNotCalled() { + assertDidReceivePushNotification(contentType: .fileProvider) } // MARK: - Private helpers @@ -124,6 +107,64 @@ final class CallKitPushNotificationAdapterTests: XCTestCase { for: .voIP ) } + + private func assertDidReceivePushNotification( + _ content: CallKitPushNotificationAdapter.Content? = nil, + contentType: PKPushType = .voIP, + displayName: String = "", + file: StaticString = #file, + line: UInt = #line + ) { + let pushPayload = MockPKPushPayload() + pushPayload.stubType = contentType + pushPayload.stubDictionaryPayload = content.map { [ + "stream": [ + "call_cid": $0.cid, + "call_display_name": displayName, + "created_by_display_name": $0.localizedCallerName, + "created_by_id": $0.callerId + ] + ] } ?? [:] + + let completionWasCalledExpectation = expectation(description: "Completion was called.") + completionWasCalledExpectation.isInverted = content == nil + subject.pushRegistry( + subject.registry, + didReceiveIncomingPushWith: pushPayload, + for: pushPayload.type, + completion: { completionWasCalledExpectation.fulfill() } + ) + + if let content { + XCTAssertEqual( + callKitService.reportIncomingCallWasCalled?.cid, + content.cid, + file: file, + line: line + ) + XCTAssertEqual( + callKitService.reportIncomingCallWasCalled?.callerName, + displayName.isEmpty ? content.localizedCallerName : displayName, + file: file, + line: line + ) + XCTAssertEqual( + callKitService.reportIncomingCallWasCalled?.callerId, + content.callerId, + file: file, + line: line + ) + callKitService.reportIncomingCallWasCalled?.completion(nil) + } else { + XCTAssertNil( + callKitService.reportIncomingCallWasCalled, + file: file, + line: line + ) + } + + wait(for: [completionWasCalledExpectation], timeout: defaultTimeout) + } } // MARK: - Mocks diff --git a/docusaurus/docs/iOS/06-advanced/03-callkit-integration.mdx b/docusaurus/docs/iOS/06-advanced/03-callkit-integration.mdx index c7f9acf89..db978e00c 100644 --- a/docusaurus/docs/iOS/06-advanced/03-callkit-integration.mdx +++ b/docusaurus/docs/iOS/06-advanced/03-callkit-integration.mdx @@ -130,6 +130,19 @@ struct MyCustomView: View { By doing that, the `CallKitAdapter` will make sure to unregister the `VoIP` token from receiving notifications._createMdxContent +#### Call display name + +The Stream backend fills 2 properties in the VoIP push notification payload that can be used as the display name of the call. +- **call_display_name** +The `call_display_name` is a calculated property that evaluates the following properties on the Call object, in the order they are being presented: + - `display_name` + - `name` + - `title` +If none of the fields above are being set, the property will be empty. + +- **created_by_display_name** +The property is always set and contains the name of the user who created the call. + ### VoIP Token Observation Even though by using the `CallKitAdapter` abstracts most of the `CallKit` & `PushKit` complexity from you, there are still cases where you may want to acces the device's `VoIP` token (e.g. to persist it ). @@ -215,4 +228,4 @@ The important part is the `onContinueUserActivity`, where we listen to `INStartC Additionally, if you have integration with the native contacts on iOS (`Contacts` framework), you can extract the full name, phone number etc, and use those to provide more details for the members. Alternatively, you can call our `queryUsers` method to get more user information that's available on the Stream backend. -If you are using UIKit, you should implement the method `application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void)` in your `AppDelegate`, and provide a similar handling as in the SwiftUI sample. \ No newline at end of file +If you are using UIKit, you should implement the method `application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void)` in your `AppDelegate`, and provide a similar handling as in the SwiftUI sample.