From 9ec69110055f943fc28ea889f019f07a0c042546 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 14 Mar 2023 11:12:40 +0300 Subject: [PATCH] ios: CallKit integration (#1969) * ios: CallKit integration * notifying CallKit about outgoing call * changes * switching calls with CallKit * string * add NSE filtering entitlement * add NSE build scheme * remove some call limitations * calls enhancments * fixed calls on lockscreen * don't display useless notification * fix app state * ability to answer on call from chat item via CallKit --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 20 + apps/ios/Shared/Model/SimpleXAPI.swift | 25 +- apps/ios/Shared/Model/SuspendChat.swift | 21 + apps/ios/Shared/SimpleXApp.swift | 28 +- .../Shared/Views/Call/ActiveCallView.swift | 6 +- .../Shared/Views/Call/CallController.swift | 358 +++++++++++------- apps/ios/Shared/Views/Call/CallManager.swift | 35 +- apps/ios/Shared/Views/Call/WebRTCClient.swift | 4 +- .../Views/UserSettings/CallSettings.swift | 21 +- .../Views/UserSettings/SettingsView.swift | 2 + .../ios/SimpleX NSE/NotificationService.swift | 20 + apps/ios/SimpleX NSE/SimpleX NSE.entitlements | 2 + apps/ios/SimpleX--iOS--Info.plist | 5 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 + .../xcschemes/SimpleX NSE.xcscheme | 97 +++++ apps/ios/SimpleXChat/AppGroup.swift | 8 +- apps/ios/SimpleXChat/CallTypes.swift | 7 +- 17 files changed, 477 insertions(+), 194 deletions(-) create mode 100644 apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX NSE.xcscheme diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 10930ac312..2baf365c25 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Intents import SimpleXChat struct ContentView: View { @@ -79,6 +80,25 @@ struct ContentView: View { } IncomingCallView() } + .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) + .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) + .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) + } + + private func processUserActivity(_ activity: NSUserActivity) { + let callToContact = { (contactId: ChatId?, mediaType: CallMediaType) in + if let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, + case let .direct(contact) = chatInfo { + CallController.shared.startCall(contact, mediaType) + } + } + if let intent = activity.interaction?.intent as? INStartCallIntent { + callToContact(intent.contacts?.first?.personHandle?.value, .audio) + } else if let intent = activity.interaction?.intent as? INStartAudioCallIntent { + callToContact(intent.contacts?.first?.personHandle?.value, .audio) + } else if let intent = activity.interaction?.intent as? INStartVideoCallIntent { + callToContact(intent.contacts?.first?.personHandle?.value, .video) + } } private func runAuthenticate() { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2dcf79922c..a86c602c3b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1214,19 +1214,6 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .callInvitation(invitation): m.callInvitations[invitation.contact.id] = invitation activateCall(invitation) - -// This will be called from notification service extension -// CXProvider.reportNewIncomingVoIPPushPayload([ -// "displayName": contact.displayName, -// "contactId": contact.id, -// "uuid": invitation.callkitUUID -// ]) { error in -// if let error = error { -// logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription)") -// } else { -// logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") -// } -// } case let .callOffer(_, contact, callType, offer, sharedKey, _): withCall(contact) { call in call.callState = .offerReceived @@ -1259,7 +1246,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } withCall(contact) { call in m.callCommand = .end -// CallController.shared.reportCallRemoteEnded(call: call) + CallController.shared.reportCallRemoteEnded(call: call) } case .chatSuspended: chatSuspended() @@ -1310,8 +1297,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { func refreshCallInvitations() throws { let m = ChatModel.shared - let callInvitations = try apiGetCallInvitations() - m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv } + let callInvitations = try justRefreshCallInvitations() if let (chatId, ntfAction) = m.ntfCallInvitationAction, let invitation = m.callInvitations.removeValue(forKey: chatId) { m.ntfCallInvitationAction = nil @@ -1321,6 +1307,13 @@ func refreshCallInvitations() throws { } } +func justRefreshCallInvitations() throws -> [RcvCallInvitation] { + let m = ChatModel.shared + let callInvitations = try apiGetCallInvitations() + m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv } + return callInvitations +} + func activateCall(_ callInvitation: RcvCallInvitation) { let m = ChatModel.shared CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 499dbbb1f7..7804e2e826 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -81,3 +81,24 @@ func activateChat(appState: AppState = .active) { if ChatModel.ok { apiActivateChat() } } } + +func initChatAndMigrate() { + let m = ChatModel.shared + if (!m.chatInitialized) { + do { + m.v3DBMigration = v3DBMigrationDefault.get() + try initializeChat(start: m.v3DBMigration.startChat) + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } + } +} + +func startChatAndActivate() { + if ChatModel.shared.chatRunning == true { + ChatReceiver.shared.start() + } + if .active != appStateGroupDefault.get() { + activateChat() + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index c8b641d20b..a05da1ddfe 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -41,32 +41,30 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { - if (!chatModel.chatInitialized) { - do { - chatModel.v3DBMigration = v3DBMigrationDefault.get() - try initializeChat(start: chatModel.v3DBMigration.startChat) - } catch let error { - fatalError("Failed to start or load chats: \(responseError(error))") - } - } + initChatAndMigrate() } .onChange(of: scenePhase) { phase in - logger.debug("scenePhase \(String(describing: scenePhase))") + logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") switch (phase) { case .background: - suspendChat() - BGManager.shared.schedule() + if CallController.useCallKit() && chatModel.activeCall != nil { + CallController.shared.onEndCall = { + suspendChat() + BGManager.shared.schedule() + } + } else { + suspendChat() + BGManager.shared.schedule() + } if userAuthorized == true { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: - if chatModel.chatRunning == true { - ChatReceiver.shared.start() - } + CallController.shared.onEndCall = nil let appState = appStateGroupDefault.get() - activateChat() + startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { updateChats() updateCallInvitations() diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index d53b351de9..1604ab9ade 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -117,9 +117,9 @@ struct ActiveCallView: View { case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { -// if case .outgoing = call.direction { -// CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) -// } + call.direction == .outgoing + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) call.callState = .connected } if state.connectionState == "closed" { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 15332ef325..7db9e88d3a 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -7,189 +7,260 @@ // import Foundation -//import CallKit +import CallKit +import StoreKit +import PushKit import AVFoundation import SimpleXChat +import WebRTC -//class CallController: NSObject, CXProviderDelegate, ObservableObject { -class CallController: NSObject, ObservableObject { - static let useCallKit = false +class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { static let shared = CallController() -// private let provider = CXProvider(configuration: CallController.configuration) -// private let controller = CXCallController() + static let isInChina = SKStorefront().countryCode == "CHN" + static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + + private let provider = CXProvider(configuration: { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = false + configuration.supportedHandleTypes = [.generic] + configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + configuration.maximumCallGroups = 1 + configuration.maximumCallsPerCallGroup = 1 + return configuration + }()) + private let controller = CXCallController() private let callManager = CallManager() @Published var activeCallInvitation: RcvCallInvitation? + var onEndCall: (() -> Void)? = nil + var fulfillOnConnect: CXAnswerCallAction? = nil -// PKPushRegistry will be used from notification service extension -// let registry = PKPushRegistry(queue: nil) - -// static let configuration: CXProviderConfiguration = { -// let configuration = CXProviderConfiguration() -// configuration.supportsVideo = true -// configuration.supportedHandleTypes = [.generic] -// configuration.includesCallsInRecents = true // TODO disable or add option -// configuration.maximumCallsPerCallGroup = 1 -// return configuration -// }() + // PKPushRegistry is used from notification service extension + private let registry = PKPushRegistry(queue: nil) override init() { super.init() -// self.provider.setDelegate(self, queue: nil) -// self.registry.delegate = self -// self.registry.desiredPushTypes = [.voIP] + provider.setDelegate(self, queue: nil) + registry.delegate = self + registry.desiredPushTypes = [.voIP] } -// func providerDidReset(_ provider: CXProvider) { -// } + func providerDidReset(_ provider: CXProvider) { + } -// func provider(_ provider: CXProvider, perform action: CXStartCallAction) { -// logger.debug("CallController.provider CXStartCallAction") -// if callManager.startOutgoingCall(callUUID: action.callUUID) { -// action.fulfill() -// provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) -// } else { -// action.fail() -// } -// } + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + logger.debug("CallController.provider CXStartCallAction") + if callManager.startOutgoingCall(callUUID: action.callUUID) { + action.fulfill() + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) + } else { + action.fail() + } + } -// func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { -// logger.debug("CallController.provider CXAnswerCallAction") -// if callManager.answerIncomingCall(callUUID: action.callUUID) { -// action.fulfill() -// } else { -// action.fail() -// } -// } + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + logger.debug("CallController.provider CXAnswerCallAction") + if callManager.answerIncomingCall(callUUID: action.callUUID) { + // WebRTC call should be in connected state to fulfill. + // Otherwise no audio and mic working on lockscreen + fulfillOnConnect = action + } else { + action.fail() + } + } -// func provider(_ provider: CXProvider, perform action: CXEndCallAction) { -// logger.debug("CallController.provider CXEndCallAction") -// callManager.endCall(callUUID: action.callUUID) { ok in -// if ok { -// action.fulfill() -// } else { -// action.fail() -// } -// } -// } + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + logger.debug("CallController.provider CXEndCallAction") + // Should be nil here if connection was in connected state + fulfillOnConnect?.fail() + fulfillOnConnect = nil + callManager.endCall(callUUID: action.callUUID) { ok in + if ok { + action.fulfill() + } else { + action.fail() + } + } + } -// func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { -// print("timed out", #function) -// action.fulfill() -// } + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) { + action.fulfill() + } else { + action.fail() + } + } -// func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { -// print("received", #function) -//// do { -//// try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) -//// logger.debug("audioSession category set") -//// try audioSession.setActive(true) -//// logger.debug("audioSession activated") -//// } catch { -//// print(error) -//// logger.error("failed activating audio session") -//// } -// } + func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + logger.debug("timed out: \(String(describing: action))") + action.fulfill() + } -// func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { -// print("received", #function) -// } + func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + print("received", #function) + RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) + RTCAudioSession.sharedInstance().isAudioEnabled = true + do { + try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) + logger.debug("audioSession category set") + try audioSession.setActive(true) + logger.debug("audioSession activated") + } catch { + print(error) + logger.error("failed activating audio session") + } + } -// func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { -// -// } + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + print("received", #function) + RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) + RTCAudioSession.sharedInstance().isAudioEnabled = false + do { + try audioSession.setActive(false) + } catch { + print(error) + logger.error("failed deactivating audio session") + } + // Allows to accept second call while in call with a previous before suspending a chat, + // see `.onChange(of: scenePhase)` in SimpleXApp + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + if ChatModel.shared.activeCall == nil { + self?.onEndCall?() + } + } + } -// This will be needed when we have notification service extension -// func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { -// if type == .voIP { -// // Extract the call information from the push notification payload -// if let displayName = payload.dictionaryPayload["displayName"] as? String, -// let contactId = payload.dictionaryPayload["contactId"] as? String, -// let uuidStr = payload.dictionaryPayload["uuid"] as? String, -// let uuid = UUID(uuidString: uuidStr) { -// let callUpdate = CXCallUpdate() -// callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: displayName) -// provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in -// if error != nil { -// let m = ChatModel.shared -// m.callInvitations.removeValue(forKey: contactId) -// } -// // Tell PushKit that the notification is handled. -// completion() -// }) -// } -// } -// } + @objc(pushRegistry:didUpdatePushCredentials:forType:) + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + + } + + func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + if type == .voIP { + if (!ChatModel.shared.chatInitialized) { + initChatAndMigrate() + CallController.shared.onEndCall = { terminateChat() } + // CallKit will be called from different place, see SimpleXAPI.startChat() + return + } else { + startChatAndActivate() + CallController.shared.onEndCall = { + suspendChat() + BGManager.shared.schedule() + } + } + // No actual list of invitations in model before this line + let invitations = try? justRefreshCallInvitations() + logger.debug("Invitations \(String(describing: invitations))") + // Extract the call information from the push notification payload + if let displayName = payload.dictionaryPayload["displayName"] as? String, + let contactId = payload.dictionaryPayload["contactId"] as? String, + let uuid = ChatModel.shared.callInvitations.first(where: { (key, value) in value.contact.id == contactId } )?.value.callkitUUID, + let media = payload.dictionaryPayload["media"] as? String { + let callUpdate = CXCallUpdate() + callUpdate.remoteHandle = CXHandle(type: .generic, value: contactId) + callUpdate.localizedCallerName = displayName + callUpdate.hasVideo = media == CallMediaType.video.rawValue + CallController.shared.provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in + if error != nil { + ChatModel.shared.callInvitations.removeValue(forKey: contactId) + } + // Tell PushKit that the notification is handled. + completion() + }) + } + } + } func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { - logger.debug("CallController.reportNewIncomingCall") -// if CallController.useCallKit, let uuid = invitation.callkitUUID { -// let update = CXCallUpdate() -// update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName) -// update.hasVideo = invitation.peerMedia == .video -// provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) -// } else { + logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))") + if CallController.useCallKit(), let uuid = invitation.callkitUUID { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) + update.hasVideo = invitation.callType.media == .video + update.localizedCallerName = invitation.contact.displayName + provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + } else { NtfManager.shared.notifyCallInvitation(invitation) if invitation.callTs.timeIntervalSinceNow >= -180 { activeCallInvitation = invitation } -// } + } } -// func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { -// if CallController.useCallKit, let uuid = call.callkitUUID { -// provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) -// } -// } + func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { + if CallController.useCallKit() { + // Fulfilling this action only after connect, otherwise there are no audio and mic on lockscreen + fulfillOnConnect?.fulfill() + fulfillOnConnect = nil + } + } + + func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { + if CallController.useCallKit(), let uuid = call.callkitUUID { + provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) + } + } func reportCallRemoteEnded(invitation: RcvCallInvitation) { -// if CallController.useCallKit, let uuid = invitation.callkitUUID { -// provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) -// } else if invitation.contact.id == activeCallInvitation?.contact.id { + if CallController.useCallKit(), let uuid = invitation.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } else if invitation.contact.id == activeCallInvitation?.contact.id { activeCallInvitation = nil -// } + } } -// func reportCallRemoteEnded(call: Call) { -// if CallController.useCallKit, let uuid = call.callkitUUID { -// provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) -// } -// } + func reportCallRemoteEnded(call: Call) { + if CallController.useCallKit(), let uuid = call.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } func startCall(_ contact: Contact, _ media: CallMediaType) { logger.debug("CallController.startCall") let uuid = callManager.newOutgoingCall(contact, media) -// if CallController.useCallKit { -// let handle = CXHandle(type: .generic, value: contact.displayName) -// let action = CXStartCallAction(call: uuid, handle: handle) -// action.isVideo = media == .video -// requestTransaction(with: action) -// } else if callManager.startOutgoingCall(callUUID: uuid) { - if callManager.startOutgoingCall(callUUID: uuid) { - logger.debug("CallController.startCall: call started") - } else { - logger.error("CallController.startCall: no active call") + if CallController.useCallKit() { + let handle = CXHandle(type: .generic, value: contact.id) + let action = CXStartCallAction(call: uuid, handle: handle) + action.isVideo = media == .video + requestTransaction(with: action) { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: contact.id) + update.hasVideo = media == .video + update.localizedCallerName = contact.displayName + self.provider.reportCall(with: uuid, updated: update) + } + } else if callManager.startOutgoingCall(callUUID: uuid) { + if callManager.startOutgoingCall(callUUID: uuid) { + logger.debug("CallController.startCall: call started") + } else { + logger.error("CallController.startCall: no active call") + } } } func answerCall(invitation: RcvCallInvitation) { - callManager.answerIncomingCall(invitation: invitation) + if CallController.useCallKit(), let callUUID = invitation.callkitUUID { + requestTransaction(with: CXAnswerCallAction(call: callUUID)) + } else { + callManager.answerIncomingCall(invitation: invitation) + } if invitation.contact.id == self.activeCallInvitation?.contact.id { self.activeCallInvitation = nil } } func endCall(callUUID: UUID) { -// if CallController.useCallKit { -// requestTransaction(with: CXEndCallAction(call: callUUID)) -// } else { + if CallController.useCallKit() { + requestTransaction(with: CXEndCallAction(call: callUUID)) + } else { callManager.endCall(callUUID: callUUID) { ok in if ok { logger.debug("CallController.endCall: call ended") } else { - logger.error("CallController.endCall: no actove call pr call invitation to end") + logger.error("CallController.endCall: no active call pr call invitation to end") } } -// } + } } func endCall(invitation: RcvCallInvitation) { @@ -213,15 +284,20 @@ class CallController: NSObject, ObservableObject { } } -// private func requestTransaction(with action: CXAction) { -// let t = CXTransaction() -// t.addAction(action) -// controller.request(t) { error in -// if let error = error { -// logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") -// } else { -// logger.debug("CallController.requestTransaction requested transaction successfully") -// } -// } -// } + func showInRecents(_ show: Bool) { + let conf = provider.configuration + conf.includesCallsInRecents = show + provider.configuration = conf + } + + private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) { + controller.request(CXTransaction(action: action)) { error in + if let error = error { + logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") + } else { + logger.debug("CallController.requestTransaction requested transaction successfully") + onSuccess() + } + } + } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index a87fbc425f..6e3066d1a0 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -48,18 +48,31 @@ class CallManager { sharedKey: invitation.sharedKey ) call.speakerEnabled = invitation.callType.media == .video - m.activeCall = call - m.showCallView = true let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let iceServers = getIceServers() logger.debug("answerIncomingCall useRelay: \(useRelay)") logger.debug("answerIncomingCall iceServers: \(String(describing: iceServers))") - m.callCommand = .start( - media: invitation.callType.media, - aesKey: invitation.sharedKey, - iceServers: iceServers, - relay: useRelay - ) + // When in active call user wants to accept another call, this can only work after delay (to hide and show activeCallView) + DispatchQueue.main.asyncAfter(deadline: .now() + (m.activeCall == nil ? 0 : 1)) { + m.activeCall = call + m.showCallView = true + + m.callCommand = .start( + media: invitation.callType.media, + aesKey: invitation.sharedKey, + iceServers: iceServers, + relay: useRelay + ) + } + } + + func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { + if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { + let m = ChatModel.shared + m.callCommand = .media(media: media, enable: enable) + return true + } + return false } func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) { @@ -82,17 +95,15 @@ class CallManager { } else { logger.debug("CallManager.endCall: ending call...") m.callCommand = .end + m.activeCall = nil m.showCallView = false + completed() Task { do { try await apiEndCall(call.contact) } catch { logger.error("CallController.provider apiEndCall error: \(responseError(error))") } - DispatchQueue.main.async { - m.activeCall = nil - completed() - } } } } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 582faef73d..7118d04a79 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -47,6 +47,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg self.sendCallResponse = sendCallResponse self.activeCall = activeCall self.localRendererAspectRatio = localRendererAspectRatio + rtcAudioSession.useManualAudio = CallController.useCallKit() + rtcAudioSession.isAudioEnabled = !CallController.useCallKit() super.init() } @@ -562,7 +564,7 @@ extension WebRTCClient { try self.rtcAudioSession.overrideOutputAudioPort(.none) try self.rtcAudioSession.setActive(false) } catch let error { - logger.debug("Error configuring AVAudioSession: \(error)") + logger.debug("Error configuring AVAudioSession with defaults: \(error)") } } } diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 254820be3a..cfc18011bb 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -7,9 +7,14 @@ // import SwiftUI +import SimpleXChat struct CallSettings: View { @AppStorage(DEFAULT_WEBRTC_POLICY_RELAY) private var webrtcPolicyRelay = true + @AppStorage(GROUP_DEFAULT_CALL_KIT_ENABLED, store: UserDefaults(suiteName: APP_GROUP_NAME)!) private var callKitEnabled = true + @AppStorage(DEFAULT_CALL_KIT_CALLS_IN_RECENTS) private var callKitCallsInRecents = false + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + private let allowChangingCallsHistory = false var body: some View { VStack { @@ -17,6 +22,18 @@ struct CallSettings: View { Section { Toggle("Connect via relay", isOn: $webrtcPolicyRelay) + if !CallController.isInChina && developerTools { + Toggle("Use CallKit", isOn: $callKitEnabled) + + if allowChangingCallsHistory { + Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) + .disabled(!callKitEnabled) + .onChange(of: callKitCallsInRecents) { value in + CallController.shared.showInRecents(value) + } + } + } + NavigationLink { RTCServers() .navigationTitle("Your ICE servers") @@ -36,9 +53,7 @@ struct CallSettings: View { Section("Limitations") { VStack(alignment: .leading, spacing: 8) { textListItem("1.", "Do NOT use SimpleX for emergency calls.") - textListItem("2.", "The microphone does not work when the app is in the background.") - textListItem("3.", "To prevent the call interruption, enable Do Not Disturb mode.") - textListItem("4.", "If the video fails to connect, flip the camera to resolve it.") + textListItem("2.", "To prevent the call interruption, enable Do Not Disturb mode.") } .font(.callout) .padding(.vertical, 8) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index cb08dab92f..4f439de6f6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -22,6 +22,7 @@ let DEFAULT_PERFORM_LA = "performLocalAuthentication" let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" +let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" @@ -47,6 +48,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PERFORM_LA: false, DEFAULT_NOTIFICATION_ALERT_SHOWN: false, DEFAULT_WEBRTC_POLICY_RELAY: true, + DEFAULT_CALL_KIT_CALLS_IN_RECENTS: false, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true, DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: "description", diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index b6efc20869..701cfd9433 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -8,6 +8,8 @@ import UserNotifications import OSLog +import StoreKit +import CallKit import SimpleXChat let logger = Logger() @@ -206,6 +208,9 @@ func chatRecvMsg() async -> ChatResponse? { } } +private let isInChina = SKStorefront().countryCode == "CHN" +private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? { logger.debug("NotificationService processReceivedMsg: \(res.responseType)") switch res { @@ -237,6 +242,21 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification } return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil case let .callInvitation(invitation): + // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit + if useCallKit() { + CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": invitation.contact.displayName, + "contactId": invitation.contact.id, + "media": invitation.callType.media.rawValue + ]) { error in + if let error = error { + logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription, privacy: .public)") + } else { + logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") + } + } + return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) + } return (invitation.contact.id, createCallInvitationNtf(invitation)) default: logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") diff --git a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements index 51dea2c806..5793de3732 100644 --- a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements +++ b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements @@ -10,5 +10,7 @@ $(AppIdentifierPrefix)chat.simplex.app + com.apple.developer.usernotifications.filtering + diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 2097b2029f..229a137e09 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -45,9 +45,14 @@ ITSAppUsesNonExemptEncryption + NSUserActivityTypes + + INStartCallIntent + UIBackgroundModes audio + bluetooth-central fetch remote-notification voip diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 184cc158d9..f108108bc0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,6 +167,8 @@ 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; + D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; + D741547A29AF90B00022400A /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547929AF90B00022400A /* PushKit.framework */; }; D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; /* End PBXBuildFile section */ @@ -411,6 +413,8 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; + D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; + D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; /* End PBXFileReference section */ @@ -420,9 +424,11 @@ buildActionMask = 2147483647; files = ( 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */, + D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */, D7197A1829AE89660055C05A /* WebRTC in Frameworks */, D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */, D7F0E33929964E7E0068AF69 /* LZString in Frameworks */, + D741547A29AF90B00022400A /* PushKit.framework in Frameworks */, 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, ); @@ -524,6 +530,8 @@ 5C764E7A279C71D4000C6508 /* Frameworks */ = { isa = PBXGroup; children = ( + D741547929AF90B00022400A /* PushKit.framework */, + D741547729AF89AF0022400A /* StoreKit.framework */, 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */, 5CDCAD6028187D7900503DA2 /* libz.tbd */, 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, @@ -1482,6 +1490,7 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 127; DEVELOPMENT_TEAM = 5NN7GUYB6T; @@ -1499,6 +1508,7 @@ MARKETING_VERSION = 4.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1512,6 +1522,7 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 127; DEVELOPMENT_TEAM = 5NN7GUYB6T; @@ -1529,6 +1540,7 @@ MARKETING_VERSION = 4.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX NSE.xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX NSE.xcscheme new file mode 100644 index 0000000000..e01b34199b --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX NSE.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 6b31d661a9..3ea392c229 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -29,8 +29,9 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" let GROUP_DEFAULT_INCOGNITO = "incognito" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" +public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" -let APP_GROUP_NAME = "group.chat.simplex.app" +public let APP_GROUP_NAME = "group.chat.simplex.app" public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! @@ -50,7 +51,8 @@ public func registerGroupDefaults() { GROUP_DEFAULT_STORE_DB_PASSPHRASE: true, GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, - GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false + GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, + GROUP_DEFAULT_CALL_KIT_ENABLED: true ]) } @@ -119,6 +121,8 @@ public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) +public let callKitEnabledGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CALL_KIT_ENABLED) + public class DateDefault { var defaults: UserDefaults var key: String diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index 915ccbd8f7..227a1fbda5 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -40,7 +40,6 @@ public struct WebRTCExtraInfo: Codable { public struct RcvCallInvitation: Decodable { public var user: User public var contact: Contact - public var callkitUUID: UUID? = UUID() public var callType: CallType public var sharedKey: String? public var callTs: Date @@ -53,6 +52,12 @@ public struct RcvCallInvitation: Decodable { } } + public var callkitUUID: UUID? = UUID() + + private enum CodingKeys: String, CodingKey { + case user, contact, callType, sharedKey, callTs + } + public static let sampleData = RcvCallInvitation( user: User.sampleData, contact: Contact.sampleData,