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