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 01/12] 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, From f123a905d5b23cf4a22526b4670ea3e8fa078e66 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 14 Mar 2023 18:19:54 +0300 Subject: [PATCH 02/12] app icon in CallKit screen (#2003) --- .../icon-transparent.imageset/120.png | Bin 0 -> 9210 bytes .../icon-transparent.imageset/180.png | Bin 0 -> 17899 bytes .../icon-transparent.imageset/60.png | Bin 0 -> 5145 bytes .../icon-transparent.imageset/Contents.json | 23 ++++++++++++++++++ .../Shared/Views/Call/CallController.swift | 1 + 5 files changed, 24 insertions(+) create mode 100644 apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/120.png create mode 100644 apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png create mode 100644 apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png create mode 100644 apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/120.png b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/120.png new file mode 100644 index 0000000000000000000000000000000000000000..9cbe08ed8424fc6140909314d270df8da42d2b99 GIT binary patch literal 9210 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 literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png new file mode 100644 index 0000000000000000000000000000000000000000..4c23ec8f2c2eb2b1bfb82abbc779c683d7fbebd0 GIT binary patch literal 17899 zcmY(q1zc3Y*8sY7g93sy2-2~1cOwnbvDDJJEZrzbi-0V>bccjWgG#z|HwY{pvVf#K z{J#JDfA8J%Obo^$3*otZiJ+&EorRU&+9d;kDIq^_o<{}?y_3%J;iPw5eCfyW5b zQC>?P0Qi(baA$+{m}az7)7Jt3f>;57h&KSh&0|)?E&u=p0s#9U06+o;08o16w&_Vd zzIfwcq7K&50&qUUxBx6PQUJyyg!Xs?(5M0bxIIDu4K$kn!un|J|G_~A0Aig1nE$~s zdyM~GYLDR`&Hu(2aJ2v92uJ@fb~7B~|KNXQr6;#Rj}e}inkfVTARzk}&;Z%F&mXy& zIvbk!nrLZC*m=71+Sq&AI`9U#d;OyVNCikdg67E@r#R#^8p3;1O#{9=iTZ8jNvDyt0~06-3)t|V_5fOZs$ z*Zfj7(CVVoGfzv9*}jm@oc+fKNTSe(jprCT1n-_w^Spmb{n>;SoJv>lL;O=(bY3FF zM|(7mOwyZBZA=Vqt7eP&tFTB#pQ>?|p&&HD>K;@HlPLfu)@a*0hymlxh3V z$m_4tWVtHAV}R+VOTW3*fl~*CX)mI!)c>xzOFmCntjXs~4F2?$mz{>W` zyCZ3w{bvLYc7Ipvd|HBMt?*C7Br7DJjEPri0uOX5gz9%_xz2K>?UU!BwenWIT5hkk z*xKI#oc!A}(?Bx#!L9DKLV>iGR(*BvQx831MCI+6@h7H zSTXTwP7x^cpYm>iX*;%asj^wP zJ5S%Z>{Gx-`RO-4iHnHt&x&^6Xm>pR4%@D%+67d2&ly{$+wPxYNrO}dM?%}1-j zDZhzQ(q$NL-Fuuy8_-Uq=V=0H#~#;=ux031{R?j57u%2

(Rm*hZi&#sex5^wS5# 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 literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png new file mode 100644 index 0000000000000000000000000000000000000000..ce09403648fd55637ce97279b5e6aa4d999877cf GIT binary patch literal 5145 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

9)Xz|g4GvUYFE;Ab@Lm&fIYYXZ4(yQA z-;HOHHJ$n(_F+nCf(8-OSEX0X5#*uD{PQ#6uE8Y2dC~k_g z^95G4DEzG-q0~9N_t@O3@fyK?M9ZxXA{`CK;W(s5;0(0-QDKy%@Z>7%UX zD`R!2oCc#08<#H#AuJ(>`|r6&!k63q8^3GuR|I(XT6)cbThwAl#(P%29RaBY96`?0 z*;bxQn+4?tO`)$jS5qt)9z8Ci8ShcO5q|VC;A&lY_g4AiJ=+I0_7$Y}^Q7hZ0*#wB z*w=;0S*h?tD)ieQ-Svt|S|#=bREhqJzFSx*IPkM6ZP$U1o(DV*L56s8;3yKlaGpPW zpR8F!U0%*NvPNt0y2YX3GP+frt z7oT=Fz}WANYqWfDT#Mk;>8tNi)mePYYRg}7NfP@+Pd1$PjG;4jzt5{%JtcO9_v~-Z=kHyhW~{!x0vy!^g`;Zm77&=?v=Q!Rmqu!_ z_O>SHsqc~KNZI^2PyT>i1b*1;6-+xBOO9p~vgQz)ZqWYLtwLNdk^3>_DBwihyV@n} zUvbfPNta+u=#+ZuK%?T)4>;BiiVomzl}ri0D@%Wht*AUB`>4OSnt$oyHxINffzE9Z tf)GQhT=WQyzrAV^b^EKGysphp2HBe2gOwU?>60H`IvUs1U#i-K{~sM09Nz!{ literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json new file mode 100644 index 0000000000..b0e2cd5ebb --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "60.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "120.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "180.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 7db9e88d3a..df724947c3 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -26,6 +26,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 + configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() return configuration }()) private let controller = CXCallController() From f48cabcc0a55602a5889b40e6f1601a382e8331f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 14 Mar 2023 18:28:34 +0300 Subject: [PATCH 03/12] ios: CallKit double call in background fix (#2004) --- apps/ios/Shared/Views/Call/CallController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index df724947c3..cfeefdb903 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -139,6 +139,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse if type == .voIP { if (!ChatModel.shared.chatInitialized) { initChatAndMigrate() + startChatAndActivate() CallController.shared.onEndCall = { terminateChat() } // CallKit will be called from different place, see SimpleXAPI.startChat() return From 0404b020e6fb36e114c0e86b17a3d09d0e37e5bc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 15 Mar 2023 13:21:21 +0300 Subject: [PATCH 04/12] ios: CallKit integrated with app lock and screen protect (#2007) * ios: CallKit integrated with app lock and screen protect * better lock mechanics * background color * logs * refactor, revert auth changes * additional state variable to allow connecting call * fix lock screen, public logs * show callkit option without dev tools --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 59 +++++++++++-------- apps/ios/Shared/SimpleXApp.swift | 9 ++- .../Shared/Views/Call/ActiveCallView.swift | 25 ++++++-- .../Shared/Views/Call/CallController.swift | 28 +++++++-- apps/ios/Shared/Views/Call/WebRTCClient.swift | 7 +++ .../Views/UserSettings/CallSettings.swift | 16 ++--- 6 files changed, 102 insertions(+), 42 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2baf365c25..88b2758726 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -13,8 +13,10 @@ struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + @Environment(\.colorScheme) var colorScheme @Binding var doAuthenticate: Bool @Binding var userAuthorized: Bool? + @Binding var canConnectCall: Bool @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -24,22 +26,29 @@ struct ContentView: View { var body: some View { ZStack { + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + } if prefPerformLA && userAuthorized != true { + Rectangle().fill(colorScheme == .dark ? .black : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(perform: {}) Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } } else if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() - } else if let step = chatModel.onboardingStage { + } else if let step = chatModel.onboardingStage, (!chatModel.showCallView || chatModel.activeCall == nil) { if case .onboardingComplete = step, chatModel.currentUser != nil { - mainView().privacySensitive(protectScreen) + mainView() } else { OnboardingView(onboarding: step) } } } .onAppear { + logger.debug("ContentView: canConnectCall \(canConnectCall), doAuthenticate \(doAuthenticate)") if doAuthenticate { runAuthenticate() } } .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } @@ -48,7 +57,7 @@ struct ContentView: View { private func mainView() -> some View { ZStack(alignment: .top) { - ChatListView() + ChatListView().privacySensitive(protectScreen) .onAppear { NtfManager.shared.requestAuthorization( onDeny: { @@ -75,31 +84,31 @@ struct ContentView: View { .sheet(isPresented: $showWhatsNew) { WhatsNewView() } - if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call) - } IncomingCallView() } - .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) - .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) - .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) +// .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 processUserActivity(_ activity: NSUserActivity) { +// let intent = activity.interaction?.intent +// if let contacts = (intent as? INStartCallIntent)?.contacts { +// callToContact(contacts, .audio) +// } else if let contacts = (intent as? INStartAudioCallIntent)?.contacts { +// callToContact(contacts, .audio) +// } else if let contacts = (intent as? INStartVideoCallIntent)?.contacts { +// callToContact(contacts, .video) +// } +// } +// +// private func callToContact(_ contacts: [INPerson], _ mediaType: CallMediaType) { +// if let contactId = contacts.first?.personHandle?.value, +// let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, +// case let .direct(contact) = chatInfo { +// CallController.shared.startCall(contact, mediaType) +// } +// } private func runAuthenticate() { if !prefPerformLA { @@ -118,10 +127,12 @@ struct ContentView: View { switch (laResult) { case .success: userAuthorized = true + canConnectCall = true case .failed: break case .unavailable: userAuthorized = true + canConnectCall = true prefPerformLA = false AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index a05da1ddfe..ec216b8063 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -20,6 +20,7 @@ struct SimpleXApp: App { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var userAuthorized: Bool? @State private var doAuthenticate = false + @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil init() { @@ -34,7 +35,7 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized) + ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") @@ -60,6 +61,7 @@ struct SimpleXApp: App { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false + canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil @@ -67,9 +69,12 @@ struct SimpleXApp: App { startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { updateChats() - updateCallInvitations() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) default: break } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 1604ab9ade..9c8b256e00 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -12,7 +12,10 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel + @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call + @Binding var userAuthorized: Bool? + @Binding var canConnectCall: Bool @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @State private var localRendererAspectRatio: CGFloat? = nil @@ -36,12 +39,19 @@ struct ActiveCallView: View { } } .onAppear { - if client == nil { - client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) - sendCommandToClient() - } + logger.debug("ActiveCallView: appear client is nil \(client == nil), userAuthorized \(userAuthorized.debugDescription, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") + createWebRTCClient() + } + .onChange(of: userAuthorized) { _ in + logger.debug("ActiveCallView: userAuthorized changed to \(userAuthorized.debugDescription, privacy: .public)") + createWebRTCClient() + } + .onChange(of: canConnectCall) { _ in + logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)") + createWebRTCClient() } .onDisappear { + logger.debug("ActiveCallView: disappear") client?.endCall() } .onChange(of: m.callCommand) { _ in sendCommandToClient()} @@ -49,6 +59,13 @@ struct ActiveCallView: View { .preferredColorScheme(.dark) } + private func createWebRTCClient() { + if client == nil && ((userAuthorized == true && canConnectCall) || scenePhase == .background) { + client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) + sendCommandToClient() + } + } + private func sendCommandToClient() { if call == m.activeCall, m.activeCall != nil, diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index cfeefdb903..a2fbe90c4c 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -21,9 +21,9 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse private let provider = CXProvider(configuration: { let configuration = CXProviderConfiguration() - configuration.supportsVideo = false + configuration.supportsVideo = true configuration.supportedHandleTypes = [.generic] - configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + configuration.includesCallsInRecents = false // UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() @@ -98,6 +98,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { print("received", #function) + logger.debug("CallController: activating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true do { @@ -113,10 +114,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { print("received", #function) + logger.debug("CallController: deactivating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = false do { try audioSession.setActive(false) + logger.debug("audioSession deactivated") } catch { print(error) logger.error("failed deactivating audio session") @@ -125,6 +128,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse // see `.onChange(of: scenePhase)` in SimpleXApp DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in if ChatModel.shared.activeCall == nil { + logger.debug("CallController: calling callback onEndCall which is \(self?.onEndCall == nil ? "nil" : "non-nil", privacy: .public)") self?.onEndCall?() } } @@ -136,14 +140,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") if type == .voIP { if (!ChatModel.shared.chatInitialized) { + logger.debug("CallController: initializing chat and returning") initChatAndMigrate() startChatAndActivate() CallController.shared.onEndCall = { terminateChat() } // CallKit will be called from different place, see SimpleXAPI.startChat() return } else { + logger.debug("CallController: starting chat (already initialized)") startChatAndActivate() CallController.shared.onEndCall = { suspendChat() @@ -162,6 +169,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse callUpdate.remoteHandle = CXHandle(type: .generic, value: contactId) callUpdate.localizedCallerName = displayName callUpdate.hasVideo = media == CallMediaType.video.rawValue + logger.debug("CallController: reporting incoming call directly to CallKit") CallController.shared.provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in if error != nil { ChatModel.shared.callInvitations.removeValue(forKey: contactId) @@ -174,7 +182,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { - logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))") + logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") if CallController.useCallKit(), let uuid = invitation.callkitUUID { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) @@ -190,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { + logger.debug("CallController: reporting incoming call connected") if CallController.useCallKit() { // Fulfilling this action only after connect, otherwise there are no audio and mic on lockscreen fulfillOnConnect?.fulfill() @@ -198,12 +207,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { + logger.debug("CallController: reporting outgoing call connected") if CallController.useCallKit(), let uuid = call.callkitUUID { provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) } } func reportCallRemoteEnded(invitation: RcvCallInvitation) { + logger.debug("CallController: reporting remote ended") if CallController.useCallKit(), let uuid = invitation.callkitUUID { provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) } else if invitation.contact.id == activeCallInvitation?.contact.id { @@ -212,6 +223,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportCallRemoteEnded(call: Call) { + logger.debug("CallController: reporting remote ended") if CallController.useCallKit(), let uuid = call.callkitUUID { provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) } @@ -241,6 +253,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func answerCall(invitation: RcvCallInvitation) { + logger.debug("CallController: answering a call") if CallController.useCallKit(), let callUUID = invitation.callkitUUID { requestTransaction(with: CXAnswerCallAction(call: callUUID)) } else { @@ -252,6 +265,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(callUUID: UUID) { + logger.debug("CallController: ending the call") if CallController.useCallKit() { requestTransaction(with: CXEndCallAction(call: callUUID)) } else { @@ -266,6 +280,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(invitation: RcvCallInvitation) { + logger.debug("CallController: ending the call") callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { @@ -276,6 +291,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(call: Call, completed: @escaping () -> Void) { + logger.debug("CallController: ending the call") callManager.endCall(call: call, completed: completed) } @@ -292,10 +308,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse provider.configuration = conf } + func hasActiveCalls() -> Bool { + controller.callObserver.calls.count > 0 + } + 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)") + logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)") } else { logger.debug("CallController.requestTransaction requested transaction successfully") onSuccess() diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 7118d04a79..f64276f9b3 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -49,6 +49,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg self.localRendererAspectRatio = localRendererAspectRatio rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit() + logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}") super.init() } @@ -241,6 +242,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } func enableMedia(_ media: CallMediaType, _ enable: Bool) { + logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) } @@ -363,6 +365,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg func endCall() { guard let call = activeCall.wrappedValue else { return } + logger.debug("WebRTCClient: ending the call") activeCall.wrappedValue = nil call.connection.close() call.connection.delegate = nil @@ -534,6 +537,7 @@ extension WebRTCClient { } func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) { + logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)") audioQueue.async { [weak self] in guard let self = self else { return } self.rtcAudioSession.lockForConfiguration() @@ -545,6 +549,7 @@ extension WebRTCClient { try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none) try self.rtcAudioSession.setActive(true) + logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") } catch let error { logger.debug("Error configuring AVAudioSession: \(error)") } @@ -552,6 +557,7 @@ extension WebRTCClient { } func audioSessionToDefaults() { + logger.debug("WebRTCClient: audioSession to defaults") audioQueue.async { [weak self] in guard let self = self else { return } self.rtcAudioSession.lockForConfiguration() @@ -563,6 +569,7 @@ extension WebRTCClient { try self.rtcAudioSession.setMode(AVAudioSession.Mode.default.rawValue) try self.rtcAudioSession.overrideOutputAudioPort(.none) try self.rtcAudioSession.setActive(false) + logger.debug("WebRTCClient: audioSession to defaults success") } catch let 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 cfc18011bb..9d3f56c710 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -22,16 +22,16 @@ struct CallSettings: View { Section { Toggle("Connect via relay", isOn: $webrtcPolicyRelay) - if !CallController.isInChina && developerTools { + if !CallController.isInChina { 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) - } - } +// if allowChangingCallsHistory { +// Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) +// .disabled(!callKitEnabled) +// .onChange(of: callKitCallsInRecents) { value in +// CallController.shared.showInRecents(value) +// } +// } } NavigationLink { From 840df89ca631eddb8c954fb89d7c54671747f3ef Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:32:27 +0300 Subject: [PATCH 05/12] ios: CallKit enhancements (#2010) * ios: CallKit enhancements * better checks --- apps/ios/Shared/AppDelegate.swift | 10 ++++++++ apps/ios/Shared/ContentView.swift | 4 ++- apps/ios/Shared/Model/ChatModel.swift | 5 ++-- apps/ios/Shared/SimpleXApp.swift | 15 +++++++++-- .../Shared/Views/Call/ActiveCallView.swift | 25 +++++++++++++------ 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index f20882383a..457aaa2824 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -94,6 +94,16 @@ class AppDelegate: NSObject, UIApplicationDelegate { return configuration } + func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) { + logger.debug("AppDelegate: will lock screen") + ChatModel.shared.onLockScreenCurrently = true + } + + func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { + logger.debug("AppDelegate: did unlock screen") + ChatModel.shared.onLockScreenCurrently = false + } + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 88b2758726..2b56580136 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -17,6 +17,7 @@ struct ContentView: View { @Binding var doAuthenticate: Bool @Binding var userAuthorized: Bool? @Binding var canConnectCall: Bool + @Binding var lastSuccessfulUnlock: TimeInterval? @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -27,7 +28,7 @@ struct ContentView: View { var body: some View { ZStack { if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + ActiveCallView(call: call, canConnectCall: $canConnectCall) } if prefPerformLA && userAuthorized != true { Rectangle().fill(colorScheme == .dark ? .black : .white) @@ -128,6 +129,7 @@ struct ContentView: View { case .success: userAuthorized = true canConnectCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .failed: break case .unavailable: diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ac00f62689..11d7bce826 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -9,7 +9,6 @@ import Foundation import Combine import SwiftUI -import WebKit import SimpleXChat final class ChatModel: ObservableObject { @@ -59,7 +58,9 @@ final class ChatModel: ObservableObject { @Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches @Published var draft: ComposeState? @Published var draftChatId: String? - var callWebView: WKWebView? + + var sceneWasActiveAtLeastOnce = false + var onLockScreenCurrently = false var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index ec216b8063..1e25e15426 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -22,6 +22,7 @@ struct SimpleXApp: App { @State private var doAuthenticate = false @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil + @State private var lastSuccessfulUnlock: TimeInterval? = nil init() { hs_init(0, nil) @@ -35,7 +36,7 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall, lastSuccessfulUnlock: $lastSuccessfulUnlock) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") @@ -65,6 +66,7 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil + chatModel.sceneWasActiveAtLeastOnce = true let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { @@ -74,7 +76,7 @@ struct SimpleXApp: App { } } doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } @@ -114,6 +116,15 @@ struct SimpleXApp: App { } } + + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 + } else { + return false + } + } + private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 9c8b256e00..98a006ae92 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -14,7 +14,6 @@ struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call - @Binding var userAuthorized: Bool? @Binding var canConnectCall: Bool @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @@ -39,11 +38,7 @@ struct ActiveCallView: View { } } .onAppear { - logger.debug("ActiveCallView: appear client is nil \(client == nil), userAuthorized \(userAuthorized.debugDescription, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") - createWebRTCClient() - } - .onChange(of: userAuthorized) { _ in - logger.debug("ActiveCallView: userAuthorized changed to \(userAuthorized.debugDescription, privacy: .public)") + logger.debug("ActiveCallView: appear client is nil \(client == nil), canConnectCall \(canConnectCall, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") createWebRTCClient() } .onChange(of: canConnectCall) { _ in @@ -60,8 +55,22 @@ struct ActiveCallView: View { } private func createWebRTCClient() { - if client == nil && ((userAuthorized == true && canConnectCall) || scenePhase == .background) { - client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) + if client == nil && (canConnectCall || m.onLockScreenCurrently) { + createWebRTCClientWithoutWait() + } else if (!m.sceneWasActiveAtLeastOnce) { + // This code waits a second until it recheck `sceneWasActiveAtLeastOnce`. + // It helps to know whether a call from lockscreen or not. + // After the second `sceneWasActiveAtLeastOnce` will still be false when the call from lockscreen + Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + createWebRTCClientWithoutWait() + } + } + } + + private func createWebRTCClientWithoutWait() { + if client == nil && (canConnectCall || !m.sceneWasActiveAtLeastOnce || m.onLockScreenCurrently) { + client = WebRTCClient($activeCall, { msg in await MainActor.run {processRtcMessage(msg: msg)} }, $localRendererAspectRatio) sendCommandToClient() } } From 2643ea90660bfea4caef18d9b62f72181e485119 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 16 Mar 2023 00:09:33 +0300 Subject: [PATCH 06/12] ios: reverted some changes related to lockScreen (#2011) * Revert "ios: CallKit enhancements (#2010)" This reverts commit 840df89ca631eddb8c954fb89d7c54671747f3ef. * Revert "ios: CallKit integrated with app lock and screen protect (#2007)" This reverts commit 0404b020e6fb36e114c0e86b17a3d09d0e37e5bc. * ios: reverted some changes related to lockScreen * undo delay * better support of appLock + call * refactor * refactor 2 * refactor 3 * refactor 4 --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 10 -- apps/ios/Shared/ContentView.swift | 125 +++++++++++------- apps/ios/Shared/Model/ChatModel.swift | 3 - apps/ios/Shared/SimpleXApp.swift | 4 +- .../Shared/Views/Call/ActiveCallView.swift | 24 +--- .../Shared/Views/Call/CallController.swift | 8 +- 6 files changed, 88 insertions(+), 86 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 457aaa2824..f20882383a 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -94,16 +94,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { return configuration } - func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) { - logger.debug("AppDelegate: will lock screen") - ChatModel.shared.onLockScreenCurrently = true - } - - func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { - logger.debug("AppDelegate: did unlock screen") - ChatModel.shared.onLockScreenCurrently = false - } - private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2b56580136..2bfec8368d 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -27,48 +27,64 @@ struct ContentView: View { var body: some View { ZStack { + contentView() if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - } - if prefPerformLA && userAuthorized != true { - Rectangle().fill(colorScheme == .dark ? .black : .white) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(perform: {}) - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } - } else if let status = chatModel.chatDbStatus, status != .ok { - DatabaseErrorView(status: status) - } else if !chatModel.v3DBMigration.startChat { - MigrateToAppGroupView() - } else if let step = chatModel.onboardingStage, (!chatModel.showCallView || chatModel.activeCall == nil) { - if case .onboardingComplete = step, - chatModel.currentUser != nil { - mainView() - } else { - OnboardingView(onboarding: step) - } + callView(call) } } .onAppear { - logger.debug("ContentView: canConnectCall \(canConnectCall), doAuthenticate \(doAuthenticate)") - if doAuthenticate { runAuthenticate() } + if prefPerformLA { requestNtfAuthorization() } + initAuthenticate() + } + .onChange(of: doAuthenticate) { _ in + initAuthenticate() } - .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } + @ViewBuilder private func contentView() -> some View { + if prefPerformLA && userAuthorized != true { + lockButton() + } else if let status = chatModel.chatDbStatus, status != .ok { + DatabaseErrorView(status: status) + } else if !chatModel.v3DBMigration.startChat { + MigrateToAppGroupView() + } else if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + chatModel.currentUser != nil { + mainView() + } else { + OnboardingView(onboarding: step) + } + } + } + + @ViewBuilder private func callView(_ call: Call) -> some View { + if CallController.useCallKit() { + ActiveCallView(call: call, canConnectCall: Binding.constant(true)) + .onDisappear { + if userAuthorized == false && doAuthenticate { runAuthenticate() } + } + } else { + ActiveCallView(call: call, canConnectCall: $canConnectCall) + if prefPerformLA && userAuthorized != true { + Rectangle() + .fill(colorScheme == .dark ? .black : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + lockButton() + } + } + } + + private func lockButton() -> some View { + Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + } + private func mainView() -> some View { ZStack(alignment: .top) { ChatListView().privacySensitive(protectScreen) .onAppear { - NtfManager.shared.requestAuthorization( - onDeny: { - if (!notificationAlertShown) { - notificationAlertShown = true - alertManager.showAlert(notificationAlert()) - } - }, - onAuthorized: { notificationAlertShown = false } - ) + if !prefPerformLA { requestNtfAuthorization() } // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -93,24 +109,29 @@ struct ContentView: View { } // private func processUserActivity(_ activity: NSUserActivity) { -// let intent = activity.interaction?.intent -// if let contacts = (intent as? INStartCallIntent)?.contacts { -// callToContact(contacts, .audio) -// } else if let contacts = (intent as? INStartAudioCallIntent)?.contacts { -// callToContact(contacts, .audio) -// } else if let contacts = (intent as? INStartVideoCallIntent)?.contacts { -// callToContact(contacts, .video) -// } -// } -// -// private func callToContact(_ contacts: [INPerson], _ mediaType: CallMediaType) { -// if let contactId = contacts.first?.personHandle?.value, -// let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, -// case let .direct(contact) = chatInfo { -// CallController.shared.startCall(contact, mediaType) +// 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 initAuthenticate() { + if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { + userAuthorized = false + } else if doAuthenticate { + runAuthenticate() + } + } + private func runAuthenticate() { if !prefPerformLA { userAuthorized = true @@ -134,13 +155,25 @@ struct ContentView: View { break case .unavailable: userAuthorized = true - canConnectCall = true prefPerformLA = false + canConnectCall = true AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } + func requestNtfAuthorization() { + NtfManager.shared.requestAuthorization( + onDeny: { + if (!notificationAlertShown) { + notificationAlertShown = true + alertManager.showAlert(notificationAlert()) + } + }, + onAuthorized: { notificationAlertShown = false } + ) + } + func laNoticeAlert() -> Alert { Alert( title: Text("SimpleX Lock"), diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 11d7bce826..9100f7bcb9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -59,9 +59,6 @@ final class ChatModel: ObservableObject { @Published var draft: ComposeState? @Published var draftChatId: String? - var sceneWasActiveAtLeastOnce = false - var onLockScreenCurrently = false - var messageDelivery: Dictionary Void> = [:] var filesToDelete: [String] = [] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e25e15426..08fca3af05 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -20,8 +20,8 @@ struct SimpleXApp: App { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var userAuthorized: Bool? @State private var doAuthenticate = false - @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil + @State private var canConnectCall = false @State private var lastSuccessfulUnlock: TimeInterval? = nil init() { @@ -66,7 +66,6 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil - chatModel.sceneWasActiveAtLeastOnce = true let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { @@ -116,7 +115,6 @@ struct SimpleXApp: App { } } - private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 98a006ae92..e4a9385706 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -12,12 +12,12 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel - @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call - @Binding var canConnectCall: Bool + @Environment(\.scenePhase) var scenePhase @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @State private var localRendererAspectRatio: CGFloat? = nil + @Binding var canConnectCall: Bool var body: some View { ZStack(alignment: .bottom) { @@ -38,7 +38,7 @@ struct ActiveCallView: View { } } .onAppear { - logger.debug("ActiveCallView: appear client is nil \(client == nil), canConnectCall \(canConnectCall, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") + logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") createWebRTCClient() } .onChange(of: canConnectCall) { _ in @@ -55,22 +55,8 @@ struct ActiveCallView: View { } private func createWebRTCClient() { - if client == nil && (canConnectCall || m.onLockScreenCurrently) { - createWebRTCClientWithoutWait() - } else if (!m.sceneWasActiveAtLeastOnce) { - // This code waits a second until it recheck `sceneWasActiveAtLeastOnce`. - // It helps to know whether a call from lockscreen or not. - // After the second `sceneWasActiveAtLeastOnce` will still be false when the call from lockscreen - Task { - try? await Task.sleep(nanoseconds: 1_000_000_000) - createWebRTCClientWithoutWait() - } - } - } - - private func createWebRTCClientWithoutWait() { - if client == nil && (canConnectCall || !m.sceneWasActiveAtLeastOnce || m.onLockScreenCurrently) { - client = WebRTCClient($activeCall, { msg in await MainActor.run {processRtcMessage(msg: msg)} }, $localRendererAspectRatio) + if client == nil && canConnectCall { + client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) sendCommandToClient() } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index a2fbe90c4c..7ad756b1c0 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -97,7 +97,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { - print("received", #function) logger.debug("CallController: activating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true @@ -113,7 +112,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { - print("received", #function) logger.debug("CallController: deactivating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = false @@ -265,7 +263,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(callUUID: UUID) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)") if CallController.useCallKit() { requestTransaction(with: CXEndCallAction(call: callUUID)) } else { @@ -280,7 +278,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(invitation: RcvCallInvitation) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with invitation") callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { @@ -291,7 +289,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(call: Call, completed: @escaping () -> Void) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with call instance") callManager.endCall(call: call, completed: completed) } From 809cc1f234f35b2b231e35e426877b51ca657c66 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 08:46:13 +0000 Subject: [PATCH 07/12] ios: different speaker buttons on call screen --- apps/ios/Shared/Views/Call/ActiveCallView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index e4a9385706..83b3bd5ae3 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -264,7 +264,7 @@ struct ActiveCallOverlay: View { private func endCallButton() -> some View { let cc = CallController.shared - return callButton("phone.down.fill", size: 60) { + return callButton("phone.down.fill", width: 60, height: 60) { if let uuid = call.callkitUUID { cc.endCall(callUUID: uuid) } else { @@ -286,7 +286,7 @@ struct ActiveCallOverlay: View { } private func toggleSpeakerButton() -> some View { - controlButton(call, call.speakerEnabled ? "speaker.fill" : "speaker.slash") { + controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") { Task { client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled) DispatchQueue.main.async { @@ -317,22 +317,22 @@ struct ActiveCallOverlay: View { @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View { if call.hasMedia { - callButton(imageName, size: 40, perform) + callButton(imageName, width: 50, height: 38, perform) .foregroundColor(.white) .opacity(0.85) } else { - Color.clear.frame(width: 40, height: 40) + Color.clear.frame(width: 50, height: 38) } } - private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View { + private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View { Button { perform() } label: { Image(systemName: imageName) .resizable() .scaledToFit() - .frame(maxWidth: size, maxHeight: size) + .frame(maxWidth: width, maxHeight: height) } } } From 6724de09c9fa5f50b8f5d96ff74f8357cc340461 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 16:59:05 +0000 Subject: [PATCH 08/12] ios: dismiss sheets on IncomingCallView, send notification if reportNewIncomingVoIPPushPayload fails --- .../Shared/Views/Call/CallController.swift | 3 ++- .../Shared/Views/Call/IncomingCallView.swift | 1 + .../ios/SimpleX NSE/NotificationService.swift | 21 +++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 7ad756b1c0..2b5682b9c8 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -46,6 +46,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func providerDidReset(_ provider: CXProvider) { + logger.debug("CallController.providerDidReset") } func provider(_ provider: CXProvider, perform action: CXStartCallAction) { @@ -134,7 +135,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse @objc(pushRegistry:didUpdatePushCredentials:forType:) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { - + logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)") } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 0044434efd..c2d5dabd48 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -65,6 +65,7 @@ struct IncomingCallView: View { .padding(.vertical, 12) .frame(maxWidth: .infinity) .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .onAppear { dismissAllSheets() } } private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 701cfd9433..d338774f5c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -244,18 +244,17 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification 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)") - } + do { + try await CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": invitation.contact.displayName, + "contactId": invitation.contact.id, + "media": invitation.callType.media.rawValue + ]) + logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") + return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) + } catch let error { + logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)") } - return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) } return (invitation.contact.id, createCallInvitationNtf(invitation)) default: From 063440e73523d4902b3829d48af3db66f23fb05f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 17:18:25 +0000 Subject: [PATCH 09/12] ios: remove sheets in ActiveCallView (does not work when call accepted from background via callkit) --- apps/ios/Shared/Views/Call/ActiveCallView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 83b3bd5ae3..393a370eed 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -40,6 +40,7 @@ struct ActiveCallView: View { .onAppear { logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") createWebRTCClient() + dismissAllSheets() } .onChange(of: canConnectCall) { _ in logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)") From 8145387f77d06a4ca94963b6ea49637c4859a27c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 19:57:43 +0000 Subject: [PATCH 10/12] ios: CallKit changed reporting logic (#2019) * ios: CallKit changed reporting logic * refactor, suspend chat after call when app is in background --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 10 +- apps/ios/Shared/Model/SuspendChat.swift | 4 +- apps/ios/Shared/SimpleXApp.swift | 7 +- .../Shared/Views/Call/CallController.swift | 114 ++++++++++++------ .../ios/SimpleX NSE/NotificationService.swift | 2 +- 5 files changed, 86 insertions(+), 51 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a86c602c3b..f2bb6ba198 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -909,7 +909,7 @@ func apiGetVersion() throws -> CoreVersionInfo { throw r } -func initializeChat(start: Bool, dbKey: String? = nil) throws { +func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws { logger.debug("initializeChat") let m = ChatModel.shared (m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey) @@ -925,13 +925,13 @@ func initializeChat(start: Bool, dbKey: String? = nil) throws { if m.currentUser == nil { m.onboardingStage = .step1_SimpleXInfo } else if start { - try startChat() + try startChat(refreshInvitations: refreshInvitations) } else { m.chatRunning = false } } -func startChat() throws { +func startChat(refreshInvitations: Bool = true) throws { logger.debug("startChat") let m = ChatModel.shared try setNetworkConfig(getNetCfg()) @@ -940,7 +940,9 @@ func startChat() throws { if justStarted { try getUserChatData() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) - try refreshCallInvitations() + if (refreshInvitations) { + try refreshCallInvitations() + } (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() if let token = m.deviceToken { registerToken(token: token) diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 7804e2e826..6d8108a3e3 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -82,12 +82,12 @@ func activateChat(appState: AppState = .active) { } } -func initChatAndMigrate() { +func initChatAndMigrate(refreshInvitations: Bool = true) { let m = ChatModel.shared if (!m.chatInitialized) { do { m.v3DBMigration = v3DBMigrationDefault.get() - try initializeChat(start: m.v3DBMigration.startChat) + try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) } catch let error { fatalError("Failed to start or load chats: \(responseError(error))") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 08fca3af05..b93d402a89 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -50,10 +50,7 @@ struct SimpleXApp: App { switch (phase) { case .background: if CallController.useCallKit() && chatModel.activeCall != nil { - CallController.shared.onEndCall = { - suspendChat() - BGManager.shared.schedule() - } + CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() @@ -65,7 +62,7 @@ struct SimpleXApp: App { canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: - CallController.shared.onEndCall = nil + CallController.shared.shouldSuspendChat = false let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 2b5682b9c8..365580735c 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -32,7 +32,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse private let controller = CXCallController() private let callManager = CallManager() @Published var activeCallInvitation: RcvCallInvitation? - var onEndCall: (() -> Void)? = nil + var shouldSuspendChat: Bool = false var fulfillOnConnect: CXAnswerCallAction? = nil // PKPushRegistry is used from notification service extension @@ -81,6 +81,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } else { action.fail() } + self.suspendOnEndCall() } } @@ -127,12 +128,20 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse // see `.onChange(of: scenePhase)` in SimpleXApp DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in if ChatModel.shared.activeCall == nil { - logger.debug("CallController: calling callback onEndCall which is \(self?.onEndCall == nil ? "nil" : "non-nil", privacy: .public)") - self?.onEndCall?() + logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") + self?.suspendOnEndCall() } } } + func suspendOnEndCall() { + if shouldSuspendChat { + shouldSuspendChat = false + suspendChat() + BGManager.shared.schedule() + } + } + @objc(pushRegistry:didUpdatePushCredentials:forType:) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)") @@ -140,53 +149,72 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") - if type == .voIP { - if (!ChatModel.shared.chatInitialized) { - logger.debug("CallController: initializing chat and returning") - initChatAndMigrate() - startChatAndActivate() - CallController.shared.onEndCall = { terminateChat() } - // CallKit will be called from different place, see SimpleXAPI.startChat() - return - } else { - logger.debug("CallController: starting chat (already initialized)") - 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 - logger.debug("CallController: reporting incoming call directly to CallKit") - CallController.shared.provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in + if type != .voIP { + completion() + return + } + logger.debug("CallController: initializing chat") + if (!ChatModel.shared.chatInitialized) { + initChatAndMigrate(refreshInvitations: false) + } + startChatAndActivate() + shouldSuspendChat = true + // There are no invitations in the model, as it was processed by NSE + _ = try? justRefreshCallInvitations() + // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") + // Extract the call information from the push notification payload + let m = ChatModel.shared + if let contactId = payload.dictionaryPayload["contactId"] as? String, + let invitation = m.callInvitations[contactId] { + let update = cxCallUpdate(invitation: invitation) + if let uuid = invitation.callkitUUID { + logger.debug("CallController: report pushkit call via CallKit") + let update = cxCallUpdate(invitation: invitation) + provider.reportNewIncomingCall(with: uuid, update: update) { error in if error != nil { - ChatModel.shared.callInvitations.removeValue(forKey: contactId) + m.callInvitations.removeValue(forKey: contactId) } // Tell PushKit that the notification is handled. completion() - }) + } + } else { + reportExpiredCall(update: update, completion) } + } else { + reportExpiredCall(payload: payload, completion) } } + // This function fulfils the requirement to always report a call when PushKit notification is received, + // even when there is no more active calls by the time PushKit payload is processed. + // See the note in the bottom of this article: + // https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/2875784-pushregistry + private func reportExpiredCall(update: CXCallUpdate, _ completion: @escaping () -> Void) { + logger.debug("CallController: report expired pushkit call via CallKit") + let uuid = UUID() + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if error == nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } + completion() + } + } + + private func reportExpiredCall(payload: PKPushPayload, _ completion: @escaping () -> Void) { + let update = CXCallUpdate() + let displayName = payload.dictionaryPayload["displayName"] as? String + let media = payload.dictionaryPayload["media"] as? String + update.localizedCallerName = displayName ?? NSLocalizedString("Unknown caller", comment: "callkit banner") + update.hasVideo = media == CallMediaType.video.rawValue + reportExpiredCall(update: update, completion) + } + func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") 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 + let update = cxCallUpdate(invitation: invitation) provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) } else { NtfManager.shared.notifyCallInvitation(invitation) @@ -196,6 +224,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + private func cxCallUpdate(invitation: RcvCallInvitation) -> CXCallUpdate { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) + update.hasVideo = invitation.callType.media == .video + update.localizedCallerName = invitation.contact.displayName + return update + } + func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { logger.debug("CallController: reporting incoming call connected") if CallController.useCallKit() { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index d338774f5c..d31a32e110 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -250,7 +250,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification "contactId": invitation.contact.id, "media": invitation.callType.media.rawValue ]) - logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") + logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) } catch let error { logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)") From 7a9f2202907bb1901debfe6921877c404336f8a3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 20:19:53 +0000 Subject: [PATCH 11/12] ios: do not suspend chat when switching to another callkit call (#2020) --- .../Shared/Views/Call/CallController.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 365580735c..83338804c1 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -124,21 +124,21 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse 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 { - logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") - self?.suspendOnEndCall() - } - } + suspendOnEndCall() } func suspendOnEndCall() { if shouldSuspendChat { - shouldSuspendChat = false - suspendChat() - BGManager.shared.schedule() + // The delay allows to accept the second call before suspending a chat + // see `.onChange(of: scenePhase)` in SimpleXApp + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") + if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true { + self?.shouldSuspendChat = false + suspendChat() + BGManager.shared.schedule() + } + } } } From 9db19242683f73505d42bbd4bfc94a35b2a2e2e8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:08:58 +0000 Subject: [PATCH 12/12] ios: optionally show callkit calls in recents and update settings (#2021) * ios: optionally show callkit calls in recents and update settings * refactor, fix call error when starting from recents --- apps/ios/Shared/ContentView.swift | 43 +++++++++++-------- .../Shared/Views/Call/CallController.swift | 2 +- .../Views/UserSettings/CallSettings.swift | 36 +++++++++------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2bfec8368d..aeccaf9346 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -103,26 +103,33 @@ struct ContentView: View { } IncomingCallView() } -// .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) -// .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) -// .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) + .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 processUserActivity(_ activity: NSUserActivity) { + let intent = activity.interaction?.intent + if let intent = intent as? INStartCallIntent { + callToRecentContact(intent.contacts, intent.callCapability == .videoCall ? .video : .audio) + } else if let intent = intent as? INStartAudioCallIntent { + callToRecentContact(intent.contacts, .audio) + } else if let intent = intent as? INStartVideoCallIntent { + callToRecentContact(intent.contacts, .video) + } + } + + private func callToRecentContact(_ contacts: [INPerson]?, _ mediaType: CallMediaType) { + logger.debug("callToRecentContact") + if let contactId = contacts?.first?.personHandle?.value, + let chat = chatModel.getChat(contactId), + case let .direct(contact) = chat.chatInfo { + logger.debug("callToRecentContact: schedule call") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + CallController.shared.startCall(contact, mediaType) + } + } + } private func initAuthenticate() { if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 83338804c1..3f338d771d 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -23,7 +23,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse let configuration = CXProviderConfiguration() configuration.supportsVideo = true configuration.supportedHandleTypes = [.generic] - configuration.includesCallsInRecents = false // UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 9d3f56c710..ca43faab03 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -20,26 +20,13 @@ struct CallSettings: View { VStack { List { Section { - Toggle("Connect via relay", isOn: $webrtcPolicyRelay) - - if !CallController.isInChina { - 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") } label: { Text("WebRTC ICE servers") } + Toggle("Always use relay", isOn: $webrtcPolicyRelay) } header: { Text("Settings") } footer: { @@ -50,10 +37,29 @@ struct CallSettings: View { } } + if !CallController.isInChina { + Section { + Toggle("Use iOS call interface", isOn: $callKitEnabled) + Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) + .disabled(!callKitEnabled) + .onChange(of: callKitCallsInRecents) { value in + CallController.shared.showInRecents(value) + } + } header: { + Text("Interface") + } footer: { + if callKitEnabled { + Text("You can accept calls from lock screen, without device and app authentication.") + } else { + Text("Authentication is required before the call is connected, but you may miss calls.") + } + } + } + Section("Limitations") { VStack(alignment: .leading, spacing: 8) { textListItem("1.", "Do NOT use SimpleX for emergency calls.") - textListItem("2.", "To prevent the call interruption, enable Do Not Disturb mode.") + textListItem("2.", "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions.") } .font(.callout) .padding(.vertical, 8)