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>
This commit is contained in:
Stanislav Dmitrenko
2023-03-14 11:12:40 +03:00
committed by GitHub
parent c4c93f881d
commit 9ec6911005
17 changed files with 477 additions and 194 deletions

View File

@@ -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() {

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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()

View File

@@ -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" {

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)")

View File

@@ -10,5 +10,7 @@
<array>
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
</array>
<key>com.apple.developer.usernotifications.filtering</key>
<true/>
</dict>
</plist>

View File

@@ -45,9 +45,14 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSUserActivityTypes</key>
<array>
<string>INStartCallIntent</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>bluetooth-central</string>
<string>fetch</string>
<string>remote-notification</string>
<string>voip</string>

View File

@@ -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 = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
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;

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5CDCAD442818589900503DA2"
BuildableName = "SimpleX NSE.appex"
BlueprintName = "SimpleX NSE"
ReferencedContainer = "container:SimpleX.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5CA059C9279559F40002BEB4"
BuildableName = "SimpleX.app"
BlueprintName = "SimpleX (iOS)"
ReferencedContainer = "container:SimpleX.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5CA059C9279559F40002BEB4"
BuildableName = "SimpleX.app"
BlueprintName = "SimpleX (iOS)"
ReferencedContainer = "container:SimpleX.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5CA059C9279559F40002BEB4"
BuildableName = "SimpleX.app"
BlueprintName = "SimpleX (iOS)"
ReferencedContainer = "container:SimpleX.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -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

View File

@@ -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,