diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 071fbbb95..775d58d81 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -36,6 +36,9 @@ final class ChatModel: ObservableObject { @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get() + // pending notification actions + @Published var ntfContactRequest: ChatId? + @Published var ntfCallInvitationAction: (ChatId, NtfCallAction)? // current WebRTC call @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 2cee0a66e..114baa36b 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -17,6 +17,11 @@ let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" private let ntfTimeInterval: TimeInterval = 1 +enum NtfCallAction { + case accept + case reject +} + class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { static let shared = NtfManager() @@ -32,18 +37,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { let content = response.notification.request.content let chatModel = ChatModel.shared let action = response.actionIdentifier + logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, - let chatId = content.userInfo["chatId"] as? String, - case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(contactRequest) } - } else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall), - let chatId = content.userInfo["chatId"] as? String, - let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { - let cc = CallController.shared - if action == ntfActionAcceptCall { - cc.answerCall(invitation: invitation) + let chatId = content.userInfo["chatId"] as? String { + if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { + Task { await acceptContactRequest(contactRequest) } } else { - cc.endCall(invitation: invitation) + chatModel.ntfContactRequest = chatId + } + } else if let (chatId, ntfAction) = ntfCallAction(content, action) { + if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { + CallController.shared.callAction(invitation: invitation, action: ntfAction) + } else { + chatModel.ntfCallInvitationAction = (chatId, ntfAction) } } else { chatModel.chatId = content.targetContentIdentifier @@ -51,6 +57,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { handler() } + private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? { + if content.categoryIdentifier == ntfCategoryCallInvitation, + let chatId = content.userInfo["chatId"] as? String { + if action == ntfActionAcceptCall { + return (chatId, .accept) + } else if action == ntfActionRejectCall { + return (chatId, .reject) + } + } + return nil + } + + // Handle notification when the app is in foreground func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -103,7 +122,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfCategoryContactRequest, actions: [UNNotificationAction( identifier: ntfActionAcceptContact, - title: NSLocalizedString("Accept", comment: "accept contact request via notification") + title: NSLocalizedString("Accept", comment: "accept contact request via notification"), + options: .foreground )], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 8d97ffebc..1891d3971 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -669,11 +669,16 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateContact(contact) m.removeChat(contact.activeConn.id) case let .receivedContactRequest(contactRequest): - m.addChat(Chat( - chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), - chatItems: [] - )) - NtfManager.shared.notifyContactRequest(contactRequest) + let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) + if m.hasChat(contactRequest.id) { + m.updateChatInfo(cInfo) + } else { + m.addChat(Chat( + chatInfo: cInfo, + chatItems: [] + )) + NtfManager.shared.notifyContactRequest(contactRequest) + } case let .contactUpdated(toContact): let cInfo = ChatInfo.direct(contact: toContact) if m.hasChat(toContact.id) { @@ -850,8 +855,12 @@ 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) + if let (chatId, ntfAction) = m.ntfCallInvitationAction, + let invitation = m.callInvitations.removeValue(forKey: chatId) { + m.ntfCallInvitationAction = nil + CallController.shared.callAction(invitation: invitation, action: ntfAction) + } else if let invitation = callInvitations.last { + activateCall(invitation) } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 990ac478a..657ee382f 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -120,6 +120,12 @@ struct SimpleXApp: App { } } } + if let chatId = chatModel.ntfContactRequest { + chatModel.ntfContactRequest = nil + if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { + Task { await acceptContactRequest(contactRequest) } + } + } } catch let error { logger.error("apiGetChats: cannot update chats \(responseError(error))") } @@ -128,8 +134,7 @@ struct SimpleXApp: App { private func updateCallInvitations() { do { try refreshCallInvitations() - } - catch let error { + } 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 5c0e23360..15332ef32 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -206,6 +206,13 @@ class CallController: NSObject, ObservableObject { callManager.endCall(call: call, completed: completed) } + func callAction(invitation: RcvCallInvitation, action: NtfCallAction) { + switch action { + case .accept: answerCall(invitation: invitation) + case .reject: endCall(invitation: invitation) + } + } + // private func requestTransaction(with action: CXAction) { // let t = CXTransaction() // t.addAction(action) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 1b3cdc8fd..504a8e42b 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -39,7 +39,7 @@ actor PendingNtfs { for await ntf in s { nse.setBestAttemptNtf(ntf) rcvCount -= 1 - if rcvCount == 0 { break } + if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } } logger.debug("PendingNtfs.readStream: exiting") } @@ -204,7 +204,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem } } - return (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) + return cItem.isCall() ? nil : (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) + case let .callInvitation(invitation): + return (invitation.contact.id, createCallInvitationNtf(invitation)) default: logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") return nil