diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index cc0731f2ad..7ebad52128 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -35,7 +35,7 @@ final class ChatModel: ObservableObject { @Published var notificationMode = NotificationsMode.off @Published var notificationPreview: NotificationPreviewMode? = .message // current WebRTC call - @Published var callInvitations: Dictionary = [:] + @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 8a189ef726..307f1f8a61 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -184,7 +184,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createMessageReceivedNtf(cInfo, cItem)) } - func notifyCallInvitation(_ invitation: CallInvitation) { + func notifyCallInvitation(_ invitation: RcvCallInvitation) { logger.debug("NtfManager.notifyCallInvitation") addNotification(createCallInvitationNtf(invitation)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2d0f9f775a..b28f4f0a84 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -476,6 +476,12 @@ func apiEndCall(_ contact: Contact) async throws { try await sendCommandOkResp(.apiEndCall(contact: contact)) } +func apiGetCallInvitations() throws -> [RcvCallInvitation] { + let r = chatSendCmdSync(.apiGetCallInvitations) + if case let .callInvitations(invs) = r { return invs } + throw r +} + func apiCallStatus(_ contact: Contact, _ status: String) async throws { if let callStatus = WebRTCCallStatus.init(rawValue: status) { try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) @@ -538,6 +544,7 @@ func startChat() throws { m.userSMPServers = try getUserSMPServers() let chats = try apiGetChats() m.chats = chats.map { Chat.init($0) } + try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = try apiGetNtfToken() if let token = m.deviceToken { registerToken(token: token) @@ -694,19 +701,9 @@ func processReceivedMsg(_ res: ChatResponse) async { let fileName = cItem.file?.filePath { removeFile(fileName) } - case let .callInvitation(contact, callType, sharedKey, callTs): - let uuid = UUID() - var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey, callTs: callTs) - m.callInvitations[contact.id] = invitation - CallController.shared.reportNewIncomingCall(invitation: invitation) { error in - if let error = error { - invitation.callkitUUID = nil - m.callInvitations[contact.id] = invitation - logger.error("reportNewIncomingCall error: \(error.localizedDescription)") - } else { - logger.debug("reportNewIncomingCall success") - } - } + case let .callInvitation(invitation): + m.callInvitations[invitation.contact.id] = invitation + activateCall(invitation) // This will be called from notification service extension // CXProvider.reportNewIncomingVoIPPushPayload([ @@ -790,6 +787,27 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { m.updateNetworkStatus(contact.id, .error(err)) } +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 } + if let inv = callInvitations.last { + activateCall(inv) + } +} + +func activateCall(_ callInvitation: RcvCallInvitation) { + let m = ChatModel.shared + CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in + if let error = error { + m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil + logger.error("reportNewIncomingCall error: \(error.localizedDescription)") + } else { + logger.debug("reportNewIncomingCall success") + } + } +} + private struct UserResponse: Decodable { var user: User? var error: String? diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 6b543a6577..5347851a74 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -63,8 +63,8 @@ struct SimpleXApp: App { let appState = appStateGroupDefault.get() activateChat() if appState.inactive && chatModel.chatRunning == true { - // TODO refresh call invitation updateChats() + updateCallInvitations() } doAuthenticate = authenticationExpired() default: @@ -123,4 +123,13 @@ struct SimpleXApp: App { logger.error("apiGetChats: cannot update chats \(responseError(error))") } } + + private func updateCallInvitations() { + do { + try refreshCallInvitations() + } + catch let error { + logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))") + } + } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index df103c02ea..23e0609df3 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -18,7 +18,7 @@ class CallController: NSObject, ObservableObject { // private let provider = CXProvider(configuration: CallController.configuration) // private let controller = CXCallController() private let callManager = CallManager() - @Published var activeCallInvitation: CallInvitation? + @Published var activeCallInvitation: RcvCallInvitation? // PKPushRegistry will be used from notification service extension // let registry = PKPushRegistry(queue: nil) @@ -120,7 +120,7 @@ class CallController: NSObject, ObservableObject { // } // } - func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) { + func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall") if !UserDefaults.standard.bool(forKey: DEFAULT_EXPERIMENTAL_CALLS) { return } // if CallController.useCallKit, let uuid = invitation.callkitUUID { @@ -142,7 +142,7 @@ class CallController: NSObject, ObservableObject { // } // } - func reportCallRemoteEnded(invitation: CallInvitation) { + 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 { @@ -172,7 +172,7 @@ class CallController: NSObject, ObservableObject { } } - func answerCall(invitation: CallInvitation) { + func answerCall(invitation: RcvCallInvitation) { callManager.answerIncomingCall(invitation: invitation) if invitation.contact.id == self.activeCallInvitation?.contact.id { self.activeCallInvitation = nil @@ -193,7 +193,7 @@ class CallController: NSObject, ObservableObject { // } } - func endCall(invitation: CallInvitation) { + func endCall(invitation: RcvCallInvitation) { callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 493a3a1d64..61bd3b9b31 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -34,7 +34,7 @@ class CallManager { return false } - func answerIncomingCall(invitation: CallInvitation) { + func answerIncomingCall(invitation: RcvCallInvitation) { let m = ChatModel.shared m.callInvitations.removeValue(forKey: invitation.contact.id) m.activeCall = Call( @@ -42,13 +42,13 @@ class CallManager { contact: invitation.contact, callkitUUID: invitation.callkitUUID, callState: .invitationAccepted, - localMedia: invitation.peerMedia, + localMedia: invitation.callType.media, sharedKey: invitation.sharedKey ) m.showCallView = true let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) logger.debug("answerIncomingCall useRelay \(useRelay)") - m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay) + m.callCommand = .start(media: invitation.callType.media, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay) } func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) { @@ -86,7 +86,7 @@ class CallManager { } } - func endCall(invitation: CallInvitation, completed: @escaping () -> Void) { + func endCall(invitation: RcvCallInvitation, completed: @escaping () -> Void) { ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id) Task { do { @@ -98,7 +98,7 @@ class CallManager { } } - private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? { + private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? { if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) { return invitation } diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 45651b89b1..22a14e4d5b 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -26,10 +26,10 @@ struct IncomingCallView: View { } } - private func incomingCall(_ invitation: CallInvitation) -> some View { + private func incomingCall(_ invitation: RcvCallInvitation) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { - Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green) + Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green) Text(invitation.callTypeText) } HStack { @@ -81,7 +81,7 @@ struct IncomingCallView: View { struct IncomingCallView_Previews: PreviewProvider { static var previews: some View { - CallController.shared.activeCallInvitation = CallInvitation.sampleData + CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData return IncomingCallView() } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d654ea61b3..6fa970ae62 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -52,6 +52,7 @@ public enum ChatCommand { case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case apiEndCall(contact: Contact) + case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case receiveFile(fileId: Int64) @@ -100,6 +101,7 @@ public enum ChatCommand { case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" + case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .receiveFile(fileId): return "/freceive \(fileId)" @@ -149,6 +151,7 @@ public enum ChatCommand { case .apiSendCallAnswer: return "apiSendCallAnswer" case .apiSendCallExtraInfo: return "apiSendCallExtraInfo" case .apiEndCall: return "apiEndCall" + case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" case .apiChatRead: return "apiChatRead" case .receiveFile: return "receiveFile" @@ -219,11 +222,12 @@ public enum ChatResponse: Decodable, Error { case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case callInvitation(contact: Contact, callType: CallType, sharedKey: String?, callTs: Date) + case callInvitation(callInvitation: RcvCallInvitation) case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) case callAnswer(contact: Contact, answer: WebRTCSession) case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case callEnded(contact: Contact) + case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) @@ -287,6 +291,7 @@ public enum ChatResponse: Decodable, Error { case .callAnswer: return "callAnswer" case .callExtraInfo: return "callExtraInfo" case .callEnded: return "callEnded" + case .callInvitations: return "callInvitations" case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" @@ -348,11 +353,12 @@ public enum ChatResponse: Decodable, Error { case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) - case let .callInvitation(contact, callType, sharedKey, _): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")" + case let .callInvitation(inv): return String(describing: inv) case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" case let .callEnded(contact): return "contact: \(contact.id)" + case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index 01f05ac4fd..1d5fa360ca 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -37,32 +37,24 @@ public struct WebRTCExtraInfo: Codable { public var rtcIceCandidates: String } -public struct CallInvitation { - public init(contact: Contact, callkitUUID: UUID? = nil, peerMedia: CallMediaType, sharedKey: String? = nil, callTs: Date) { - self.contact = contact - self.callkitUUID = callkitUUID - self.peerMedia = peerMedia - self.sharedKey = sharedKey - self.callTs = callTs - } - +public struct RcvCallInvitation: Decodable { public var contact: Contact - public var callkitUUID: UUID? - public var peerMedia: CallMediaType + public var callkitUUID: UUID? = UUID() + public var callType: CallType public var sharedKey: String? public var callTs: Date public var callTypeText: LocalizedStringKey { get { - switch peerMedia { + switch callType.media { case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call" case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call" } } } - public static let sampleData = CallInvitation( + public static let sampleData = RcvCallInvitation( contact: Contact.sampleData, - peerMedia: .audio, + callType: CallType(media: .audio, capabilities: CallCapabilities(encryption: false)), callTs: .now ) } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 9413e6381b..535dc2c5bb 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -50,8 +50,8 @@ public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UN ) } -public func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent { - let text = invitation.peerMedia == .video +public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutableNotificationContent { + let text = invitation.callType.media == .video ? NSLocalizedString("Incoming video call", comment: "notification") : NSLocalizedString("Incoming audio call", comment: "notification") return createNotification( diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 820c454cf1..3fb05ddb63 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -150,7 +150,7 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m (Async ()) startChatController user subConns = do asks smpAgent >>= resumeAgentClient - restoreCalls + restoreCalls user s <- asks agentAsync readTVarIO s >>= maybe (start s) (pure . fst) where @@ -162,11 +162,13 @@ startChatController user subConns = do else pure Nothing atomically . writeTVar s $ Just (a1, a2) pure a1 - restoreCalls = do - savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) - let callsMap = M.fromList $ map (\(call@Call {contactId}) -> (contactId, call)) savedCalls - calls <- asks currentCalls - atomically $ writeTVar calls callsMap + +restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +restoreCalls user = do + savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) + let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls + calls <- asks currentCalls + atomically $ writeTVar calls callsMap stopChatController :: MonadUnliftIO m => ChatController -> m () stopChatController ChatController {smpAgent, agentAsync = s} = do @@ -213,7 +215,9 @@ processChatCommand = \case APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> withAgent activateAgent $> CRCmdOk + APIActivateChat -> do + withUser $ \user -> restoreCalls user + withAgent activateAgent $> CRCmdOk APISuspendChat t -> withAgent (`suspendAgent` t) $> CRCmdOk ResubscribeAllConnections -> withUser (subscribeUserConnections resubscribeConnection) $> CRCmdOk SetFilesFolder filesFolder' -> do @@ -544,8 +548,9 @@ processChatCommand = \case SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId) updateCallItemStatus userId ct call WCSDisconnected $ Just msgId pure Nothing - APIGetCallInvitations -> withUser $ \user@User {userId} -> do - invs <- mapMaybe callInvitation <$> withStore' (`getCalls` user) + APIGetCallInvitations -> withUser $ \User {userId} -> do + calls <- asks currentCalls >>= readTVarIO + let invs = mapMaybe callInvitation $ M.elems calls CRCallInvitations <$> mapM (rcvCallInvitation userId) invs where callInvitation Call {contactId, callState, callTs} = case callState of diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 54ce2dad9c..de4336493a 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -33,6 +33,7 @@ data Call = Call callState :: CallState, callTs :: UTCTime } + deriving (Show) isRcvInvitation :: Call -> Bool isRcvInvitation Call {callState} = case callState of @@ -88,7 +89,7 @@ data CallState peerCallSession :: WebRTCSession, sharedKey :: Maybe C.Key } - deriving (Generic) + deriving (Show, Generic) -- database representation instance FromJSON CallState where diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7fda746f23..3f79794cb7 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -3693,6 +3693,7 @@ getCalls db User {userId} = do contact_id, shared_call_id, chat_item_id, call_state, call_ts FROM calls WHERE user_id = ? + ORDER BY call_ts ASC |] (Only userId) where