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 0000000000..9cbe08ed84 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/120.png differ 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 0000000000..4c23ec8f2c Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png differ 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 0000000000..ce09403648 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png differ 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/ContentView.swift b/apps/ios/Shared/ContentView.swift index 10930ac312..aeccaf9346 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -6,14 +6,18 @@ // import SwiftUI +import Intents import SimpleXChat 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 + @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 @@ -23,41 +27,64 @@ struct ContentView: View { var body: some View { ZStack { - if prefPerformLA && userAuthorized != true { - 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 { - if case .onboardingComplete = step, - chatModel.currentUser != nil { - mainView().privacySensitive(protectScreen) - } else { - OnboardingView(onboarding: step) - } + contentView() + if chatModel.showCallView, let call = chatModel.activeCall { + callView(call) } } .onAppear { - 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() + 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 @@ -74,11 +101,42 @@ 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) + } + + 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 { + userAuthorized = false + } else if doAuthenticate { + runAuthenticate() + } } private func runAuthenticate() { @@ -98,16 +156,31 @@ struct ContentView: View { switch (laResult) { case .success: userAuthorized = true + canConnectCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .failed: break case .unavailable: userAuthorized = 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 adec3f54fe..63811449e2 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,6 @@ 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 messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2dcf79922c..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) @@ -1214,19 +1216,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 +1248,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 +1299,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 +1309,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..6d8108a3e3 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(refreshInvitations: Bool = true) { + let m = ChatModel.shared + if (!m.chatInitialized) { + do { + m.v3DBMigration = v3DBMigrationDefault.get() + try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) + } 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..b93d402a89 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -21,6 +21,8 @@ struct SimpleXApp: App { @State private var userAuthorized: Bool? @State private var doAuthenticate = false @State private var enteredBackground: TimeInterval? = nil + @State private var canConnectCall = false + @State private var lastSuccessfulUnlock: TimeInterval? = nil init() { hs_init(0, nil) @@ -34,44 +36,43 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized) + ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall, lastSuccessfulUnlock: $lastSuccessfulUnlock) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") 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.shouldSuspendChat = true + } else { + suspendChat() + BGManager.shared.schedule() + } if userAuthorized == true { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false + canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: - if chatModel.chatRunning == true { - ChatReceiver.shared.start() - } + CallController.shared.shouldSuspendChat = false let appState = appStateGroupDefault.get() - activateChat() + startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { updateChats() - updateCallInvitations() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } @@ -111,6 +112,14 @@ 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 d53b351de9..393a370eed 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -13,9 +13,11 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @ObservedObject var call: Call + @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) { @@ -36,12 +38,16 @@ 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), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") + createWebRTCClient() + dismissAllSheets() + } + .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 +55,13 @@ struct ActiveCallView: View { .preferredColorScheme(.dark) } + private func createWebRTCClient() { + if client == nil && canConnectCall { + client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) + sendCommandToClient() + } + } + private func sendCommandToClient() { if call == m.activeCall, m.activeCall != nil, @@ -117,9 +130,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" { @@ -252,7 +265,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 { @@ -274,7 +287,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 { @@ -305,22 +318,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) } } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 15332ef325..3f338d771d 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -7,192 +7,315 @@ // 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 = true + configuration.supportedHandleTypes = [.generic] + 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() private let callManager = CallManager() @Published var activeCallInvitation: RcvCallInvitation? + var shouldSuspendChat: Bool = false + 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) { + logger.debug("CallController.providerDidReset") + } -// 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() + } + self.suspendOnEndCall() + } + } -// 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) { + logger.debug("CallController: activating audioSession and audio in WebRTCClient") + 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) { + 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") + } + suspendOnEndCall() + } -// 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() -// }) -// } -// } -// } + func suspendOnEndCall() { + if shouldSuspendChat { + // 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() + } + } + } + } + + @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) { + logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") + 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 { + 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") -// 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), privacy: .public)") + if CallController.useCallKit(), let uuid = invitation.callkitUUID { + let update = cxCallUpdate(invitation: invitation) + 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) -// } -// } + 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() { + // 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?) { + logger.debug("CallController: reporting outgoing call connected") + 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 { + 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 { 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) { + logger.debug("CallController: reporting remote ended") + 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) + logger.debug("CallController: answering a call") + 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 { + logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)") + 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) { + logger.debug("CallController: ending the call with invitation") callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { @@ -203,6 +326,7 @@ class CallController: NSObject, ObservableObject { } func endCall(call: Call, completed: @escaping () -> Void) { + logger.debug("CallController: ending the call with call instance") callManager.endCall(call: call, completed: completed) } @@ -213,15 +337,24 @@ 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 + } + + 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, privacy: .public)") + } 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/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/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 582faef73d..f64276f9b3 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -47,6 +47,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg self.sendCallResponse = sendCallResponse self.activeCall = activeCall 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() } @@ -239,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) } @@ -361,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 @@ -532,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() @@ -543,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)") } @@ -550,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() @@ -561,8 +569,9 @@ 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: \(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..ca43faab03 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -7,22 +7,26 @@ // 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 { List { Section { - Toggle("Connect via relay", isOn: $webrtcPolicyRelay) - NavigationLink { RTCServers() .navigationTitle("Your ICE servers") } label: { Text("WebRTC ICE servers") } + Toggle("Always use relay", isOn: $webrtcPolicyRelay) } header: { Text("Settings") } footer: { @@ -33,12 +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.", "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.", "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions.") } .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..d31a32e110 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,20 @@ 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() { + do { + try await CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": invitation.contact.displayName, + "contactId": invitation.contact.id, + "media": invitation.callType.media.rawValue + ]) + 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)") + } + } 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 52710e2685..c26663d727 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 = 128; DEVELOPMENT_TEAM = 5NN7GUYB6T; @@ -1499,6 +1508,7 @@ MARKETING_VERSION = 4.5.4; 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 = 128; DEVELOPMENT_TEAM = 5NN7GUYB6T; @@ -1529,6 +1540,7 @@ MARKETING_VERSION = 4.5.4; 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,