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,