core, ios: get messages for multiple last notifications; separately get notification connections before requesting messages (to avoid acknowledgement races in case of parralel nse threads); coordinate nse threads (#5084)

* core, ios: get messages for multiple last notifications (#5047)

* ios: refactor notification service (#5086)

* core, ios: separately get notification connections before requesting messages; coordinate nse threads (#5085)
This commit is contained in:
spaced4ndy 2024-10-25 20:09:59 +04:00 committed by GitHub
parent edf99fcd1d
commit 78510b6fd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 487 additions and 233 deletions

View file

@ -199,6 +199,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
),
UNNotificationCategory(
identifier: ntfCategoryManyEvents,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
)
])
}
@ -228,24 +234,24 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest))
addNotification(createContactRequestNtf(user, contactRequest, 0))
}
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact))
addNotification(createContactConnectedNtf(user, contact, 0))
}
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation))
addNotification(createCallInvitationNtf(invitation, 0))
}
func setNtfBadgeCount(_ count: Int) {

View file

@ -26,14 +26,52 @@ enum NSENotification {
case nse(UNMutableNotificationContent)
case callkit(RcvCallInvitation)
case empty
case msgInfo(NtfMsgAckInfo)
}
var isCallInvitation: Bool {
public enum NSENotificationData {
case connectionEvent(_ user: User, _ connEntity: ConnectionEntity)
case contactConnected(_ user: any UserLike, _ contact: Contact)
case contactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest)
case messageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem)
case callInvitation(_ invitation: RcvCallInvitation)
case msgInfo(NtfMsgAckInfo)
case noNtf
var callInvitation: RcvCallInvitation? {
switch self {
case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation
case .callkit: true
case .empty: false
case .msgInfo: false
case let .callInvitation(invitation): invitation
default: nil
}
}
func notificationContent(_ badgeCount: Int) -> UNMutableNotificationContent {
return switch self {
case let .connectionEvent(user, connEntity): createConnectionEventNtf(user, connEntity, badgeCount)
case let .contactConnected(user, contact): createContactConnectedNtf(user, contact, badgeCount)
case let .contactRequest(user, contactRequest): createContactRequestNtf(user, contactRequest, badgeCount)
case let .messageReceived(user, cInfo, cItem): createMessageReceivedNtf(user, cInfo, cItem, badgeCount)
case let .callInvitation(invitation): createCallInvitationNtf(invitation, badgeCount)
case .msgInfo: UNMutableNotificationContent()
case .noNtf: UNMutableNotificationContent()
}
}
var notificationEvent: NSENotificationData? {
return switch self {
case .connectionEvent: self
case .contactConnected: self
case .contactRequest: self
case .messageReceived: self
case .callInvitation: self
case .msgInfo: nil
case .noNtf: nil
}
}
var newMsgData: (any UserLike, ChatInfo)? {
return switch self {
case let .messageReceived(user, cInfo, _): (user, cInfo)
default: nil
}
}
}
@ -43,9 +81,10 @@ enum NSENotification {
// or when background notification is received.
class NSEThreads {
static let shared = NSEThreads()
private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
private var allThreads: Set<UUID> = []
private var activeThreads: [(UUID, NotificationService)] = []
var activeThreads: [(UUID, NotificationService)] = []
var droppedNotifications: [(ChatId, NSENotificationData)] = []
func newThread() -> UUID {
NSEThreads.queue.sync {
@ -64,22 +103,19 @@ class NSEThreads {
}
}
func processNotification(_ id: ChatId, _ ntf: NSENotification) async -> Void {
var waitTime: Int64 = 5_000_000000
while waitTime > 0 {
if let (_, nse) = rcvEntityThread(id),
nse.shouldProcessNtf && nse.processReceivedNtf(ntf) {
break
} else {
try? await Task.sleep(nanoseconds: 10_000000)
waitTime -= 10_000000
}
func processNotification(_ id: ChatId, _ ntf: NSENotificationData) async -> Void {
if let (_, nse) = rcvEntityThread(id),
nse.expectedMessages[id]?.shouldProcessNtf ?? false {
nse.processReceivedNtf(id, ntf, signalReady: true)
}
}
private func rcvEntityThread(_ id: ChatId) -> (UUID, NotificationService)? {
NSEThreads.queue.sync {
activeThreads.first(where: { (_, nse) in nse.receiveEntityId == id })
// this selects the earliest thread that:
// 1) has this connection in nse.expectedMessages
// 2) has not completed processing messages for this connection (not ready)
activeThreads.first(where: { (_, nse) in nse.expectedMessages[id]?.ready == false })
}
}
@ -106,31 +142,38 @@ class NSEThreads {
}
}
struct ExpectedMessage {
var ntfConn: UserNtfConn
var receiveConnId: String?
var expectedMsgId: String?
var allowedGetNextAttempts: Int
var msgBestAttemptNtf: NSENotificationData?
var ready: Bool
var shouldProcessNtf: Bool
var startedProcessingNewMsgs: Bool
var semaphore: DispatchSemaphore
}
// Notification service extension creates a new instance of the class and calls didReceive for each notification.
// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never
// more than one process of notification service extension exists at a time.
// Soon after notification service delivers the last notification it is either suspended or terminated.
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptNtf: NSENotification?
// served as notification if no message attempts (msgBestAttemptNtf) could be produced
var serviceBestAttemptNtf: NSENotification?
var badgeCount: Int = 0
// thread is added to allThreads here - if thread did not start chat,
// chat does not need to be suspended but NSE state still needs to be set to "suspended".
var threadId: UUID? = NSEThreads.shared.newThread()
var notificationInfo: NtfMessages?
var receiveEntityId: String?
var receiveConnId: String?
var expectedMessage: String?
var allowedGetNextAttempts: Int = 3
// return true if the message is taken - it prevents sending it to another NotificationService instance for processing
var shouldProcessNtf = false
var expectedMessages: Dictionary<String, ExpectedMessage> = [:] // key is receiveEntityId
var appSubscriber: AppSubscriber?
var returnedSuspension = false
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
logger.debug("DEBUGGING: NotificationService.didReceive")
let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
setBestAttemptNtf(ntf)
setServiceBestAttemptNtf(ntf)
self.contentHandler = contentHandler
registerGroupDefaults()
let appState = appStateGroupDefault.get()
@ -138,13 +181,11 @@ class NotificationService: UNNotificationServiceExtension {
switch appState {
case .stopped:
setBadgeCount()
setBestAttemptNtf(createAppStoppedNtf())
setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount))
deliverBestAttemptNtf()
case .suspended:
setBadgeCount()
receiveNtfMessages(request, contentHandler)
case .suspending:
setBadgeCount()
Task {
let state: AppState = await withCheckedContinuation { cont in
appSubscriber = appStateSubscriber { s in
@ -171,8 +212,9 @@ class NotificationService: UNNotificationServiceExtension {
deliverBestAttemptNtf()
}
}
default:
deliverBestAttemptNtf()
case .active: contentHandler(UNMutableNotificationContent())
case .activating: contentHandler(UNMutableNotificationContent())
case .bgRefresh: contentHandler(UNMutableNotificationContent())
}
}
@ -192,78 +234,165 @@ class NotificationService: UNNotificationServiceExtension {
if let t = threadId { NSEThreads.shared.startThread(t, self) }
let dbStatus = startChat()
if case .ok = dbStatus,
let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.receivedMsg_ == nil ? 0 : 1))")
if let connEntity = ntfInfo.connEntity_ {
setBestAttemptNtf(
ntfInfo.ntfsEnabled
? .nse(createConnectionEventNtf(ntfInfo.user, connEntity))
: .empty
)
if let id = connEntity.id, ntfInfo.expectedMsg_ != nil {
notificationInfo = ntfInfo
receiveEntityId = id
receiveConnId = connEntity.conn.agentConnId
let expectedMsgId = ntfInfo.expectedMsg_?.msgId
let receivedMsgId = ntfInfo.receivedMsg_?.msgId
logger.debug("NotificationService: receiveNtfMessages: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private), receivedMsgId = \(receivedMsgId ?? "nil", privacy: .private)")
expectedMessage = expectedMsgId
shouldProcessNtf = true
return
let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)")
for ntfConn in ntfConns {
addExpectedMessage(ntfConn: ntfConn)
}
let connIdsToGet = expectedMessages.compactMap { (id, _) in
let started = NSEThreads.queue.sync {
let canStart = checkCanStart(id)
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") }
if canStart {
processDroppedNotifications(id)
expectedMessages[id]?.startedProcessingNewMsgs = true
expectedMessages[id]?.shouldProcessNtf = true
}
return canStart
}
if started {
return expectedMessages[id]?.receiveConnId
} else {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") }
expectedMessages[id]?.semaphore.wait()
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") }
Task {
NSEThreads.queue.sync {
processDroppedNotifications(id)
expectedMessages[id]?.startedProcessingNewMsgs = true
expectedMessages[id]?.shouldProcessNtf = true
}
if let connId = expectedMessages[id]?.receiveConnId {
let _ = getConnNtfMessage(connId: connId)
}
}
return nil
}
}
if !connIdsToGet.isEmpty {
if let r = apiGetConnNtfMessages(connIds: connIdsToGet) {
logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)")
}
return
}
} else if let dbStatus = dbStatus {
setBestAttemptNtf(createErrorNtf(dbStatus))
setServiceBestAttemptNtf(createErrorNtf(dbStatus, badgeCount))
}
}
deliverBestAttemptNtf()
}
func addExpectedMessage(ntfConn: UserNtfConn) {
if let connEntity = ntfConn.connEntity_,
let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil {
let expectedMsgId = ntfConn.expectedMsg_?.msgId
logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)")
expectedMessages[receiveEntityId] = ExpectedMessage(
ntfConn: ntfConn,
receiveConnId: connEntity.conn.agentConnId,
expectedMsgId: expectedMsgId,
allowedGetNextAttempts: 3,
msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf,
ready: false,
shouldProcessNtf: false,
startedProcessingNewMsgs: false,
semaphore: DispatchSemaphore(value: 0)
)
}
}
func checkCanStart(_ entityId: String) -> Bool {
return !NSEThreads.shared.activeThreads.contains(where: {
(tId, nse) in tId != threadId && nse.expectedMessages.contains(where: { $0.key == entityId })
})
}
func processDroppedNotifications(_ entityId: String) {
if !NSEThreads.shared.droppedNotifications.isEmpty {
let messagesToProcess = NSEThreads.shared.droppedNotifications.filter { (eId, _) in eId == entityId }
NSEThreads.shared.droppedNotifications.removeAll(where: { (eId, _) in eId == entityId })
for (index, (_, ntf)) in messagesToProcess.enumerated() {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entity \(entityId, privacy: .private): processing dropped notification \(index, privacy: .private)") }
processReceivedNtf(entityId, ntf, signalReady: false)
}
}
}
override func serviceExtensionTimeWillExpire() {
logger.debug("DEBUGGING: NotificationService.serviceExtensionTimeWillExpire")
deliverBestAttemptNtf(urgent: true)
}
func processReceivedNtf(_ ntf: NSENotification) -> Bool {
guard let ntfInfo = notificationInfo, let expectedMsgTs = ntfInfo.expectedMsg_?.msgTs else { return false }
if !ntfInfo.user.showNotifications {
self.setBestAttemptNtf(.empty)
var expectingMoreMessages: Bool {
!expectedMessages.allSatisfy { $0.value.ready }
}
func processReceivedNtf(_ id: ChatId, _ ntf: NSENotificationData, signalReady: Bool) {
guard let expectedMessage = expectedMessages[id] else {
return
}
guard let expectedMsgTs = expectedMessage.ntfConn.expectedMsg_?.msgTs else {
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
return
}
if case let .msgInfo(info) = ntf {
if info.msgId == expectedMessage {
expectedMessage = nil
if info.msgId == expectedMessage.expectedMsgId {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): expected")
expectedMessages[id]?.expectedMsgId = nil
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return true
} else if let msgTs = info.msgTs_, msgTs > expectedMsgTs {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, let other instance to process it, stopping this one")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
} else if allowedGetNextAttempts > 0, let receiveConnId = receiveConnId {
} else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message")
allowedGetNextAttempts -= 1
if let receivedMsg = apiGetConnNtfMessage(connId: receiveConnId) {
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
return true
expectedMessages[id]?.allowedGetNextAttempts -= 1
if let receivedMsg = getConnNtfMessage(connId: receiveConnId) {
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
} else {
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
}
} else {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unknown message, let other instance to process it")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
}
} else if ntfInfo.user.showNotifications {
} else if expectedMessage.ntfConn.user.showNotifications {
logger.debug("NotificationService processNtf: setting best attempt")
self.setBestAttemptNtf(ntf)
if ntf.isCallInvitation {
self.deliverBestAttemptNtf()
if ntf.notificationEvent != nil {
setBadgeCount()
}
return true
let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf
if prevBestAttempt?.callInvitation != nil {
if ntf.callInvitation != nil { // replace with newer call
expectedMessages[id]?.msgBestAttemptNtf = ntf
} // otherwise keep call as best attempt
} else {
expectedMessages[id]?.msgBestAttemptNtf = ntf
}
} else {
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
}
}
func entityReady(_ entityId: ChatId) {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: entity \(entityId, privacy: .private)") }
expectedMessages[entityId]?.ready = true
if let (tNext, nse) = NSEThreads.shared.activeThreads.first(where: { (_, nse) in nse.expectedMessages[entityId]?.startedProcessingNewMsgs == false }) {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: signal next thread \(tNext, privacy: .private) for entity \(entityId, privacy: .private)") }
nse.expectedMessages[entityId]?.semaphore.signal()
}
return false
}
func setBadgeCount() {
@ -271,37 +400,32 @@ class NotificationService: UNNotificationServiceExtension {
ntfBadgeCountGroupDefault.set(badgeCount)
}
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
setBestAttemptNtf(.nse(ntf))
}
func setBestAttemptNtf(_ ntf: NSENotification) {
logger.debug("NotificationService.setBestAttemptNtf")
if case let .nse(notification) = ntf {
notification.badge = badgeCount as NSNumber
bestAttemptNtf = .nse(notification)
} else {
bestAttemptNtf = ntf
}
func setServiceBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
logger.debug("NotificationService.setServiceBestAttemptNtf")
serviceBestAttemptNtf = .nse(ntf)
}
private func deliverBestAttemptNtf(urgent: Bool = false) {
logger.debug("NotificationService.deliverBestAttemptNtf")
// stop processing other messages
shouldProcessNtf = false
if (urgent || !expectingMoreMessages) {
logger.debug("NotificationService.deliverBestAttemptNtf")
// stop processing other messages
for (key, _) in expectedMessages {
expectedMessages[key]?.shouldProcessNtf = false
}
let suspend: Bool
if let t = threadId {
threadId = nil
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
} else {
suspend = false
let suspend: Bool
if let t = threadId {
threadId = nil
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
} else {
suspend = false
}
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
}
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
}
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) {
if case .callkit = bestAttemptNtf {
if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) {
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit")
if urgent {
// suspending NSE even though there may be other notifications
@ -339,19 +463,13 @@ class NotificationService: UNNotificationServiceExtension {
}
private func deliverNotification() {
if let handler = contentHandler, let ntf = bestAttemptNtf {
if let handler = contentHandler, let ntf = prepareNotification() {
contentHandler = nil
bestAttemptNtf = nil
let deliver: (UNMutableNotificationContent?) -> Void = { ntf in
let useNtf = if let ntf = ntf {
appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf
} else {
UNMutableNotificationContent()
}
handler(useNtf)
}
serviceBestAttemptNtf = nil
switch ntf {
case let .nse(content): deliver(content)
case let .nse(content):
content.badge = badgeCount as NSNumber
handler(content)
case let .callkit(invitation):
logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)")
CXProvider.reportNewIncomingVoIPPushPayload([
@ -362,13 +480,85 @@ class NotificationService: UNNotificationServiceExtension {
"callTs": invitation.callTs.timeIntervalSince1970
]) { error in
logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
deliver(error == nil ? nil : createCallInvitationNtf(invitation))
handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(invitation, self.badgeCount))
}
case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet
case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo
case .empty:
handler(UNMutableNotificationContent()) // used to mute notifications that did not unsubscribe yet
}
}
}
private func prepareNotification() -> NSENotification? {
if expectedMessages.isEmpty {
return serviceBestAttemptNtf
} else if let callNtfKV = expectedMessages.first(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }),
let callInv = callNtfKV.value.msgBestAttemptNtf?.callInvitation,
let callNtf = callNtfKV.value.msgBestAttemptNtf {
return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount))
} else {
let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent }
if ntfEvents.isEmpty {
return .empty
} else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil {
return .nse(ntfEvent.notificationContent(badgeCount))
} else {
return .nse(createJointNtf(ntfEvents))
}
}
}
private func createJointNtf(_ ntfEvents: [NSENotificationData]) -> UNMutableNotificationContent {
let previewMode = ntfPreviewModeGroupDefault.get()
let newMsgsData: [(any UserLike, ChatInfo)] = ntfEvents.compactMap { $0.newMsgData }
if !newMsgsData.isEmpty, let userId = newMsgsData.first?.0.userId {
let newMsgsChats: [ChatInfo] = newMsgsData.map { $0.1 }
let uniqueChatsNames = uniqueNewMsgsChatsNames(newMsgsChats)
var body: String
if previewMode == .hidden {
body = String.localizedStringWithFormat(NSLocalizedString("New messages in %d chats", comment: "notification body"), uniqueChatsNames.count)
} else {
body = String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(uniqueChatsNames))
}
return createNotification(
categoryIdentifier: ntfCategoryManyEvents,
title: NSLocalizedString("New messages", comment: "notification"),
body: body,
userInfo: ["userId": userId],
badgeCount: badgeCount
)
} else {
return createNotification(
categoryIdentifier: ntfCategoryManyEvents,
title: NSLocalizedString("New events", comment: "notification"),
body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfEvents.count),
badgeCount: badgeCount
)
}
}
private func uniqueNewMsgsChatsNames(_ newMsgsChats: [ChatInfo]) -> [String] {
var seenChatIds = Set<ChatId>()
var uniqueChatsNames: [String] = []
for chat in newMsgsChats {
if !seenChatIds.contains(chat.id) {
seenChatIds.insert(chat.id)
uniqueChatsNames.append(chat.chatViewName)
}
}
return uniqueChatsNames
}
private func newMsgsChatsNamesStr(_ names: [String]) -> String {
return switch names.count {
case 1: names[0]
case 2: "\(names[0]) and \(names[1])"
case 3: "\(names[0] + ", " + names[1]) and \(names[2])"
default:
names.count > 3
? "\(names[0]), \(names[1]) and \(names.count - 2) other chats"
: ""
}
}
}
// nseStateGroupDefault must not be used in NSE directly, only via this singleton
@ -582,28 +772,25 @@ func chatRecvMsg() async -> ChatResponse? {
private let isInChina = SKStorefront().countryCode == "CHN"
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? {
logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
switch res {
case let .contactConnected(user, contact, _):
return (contact.id, .nse(createContactConnectedNtf(user, contact)))
return (contact.id, .contactConnected(user, contact))
// case let .contactConnecting(contact):
// TODO profile update
case let .receivedContactRequest(user, contactRequest):
return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest)))
return (UserContact(contactRequest: contactRequest).id, .contactRequest(user, contactRequest))
case let .newChatItems(user, chatItems):
// Received items are created one at a time
if let chatItem = chatItems.first {
let cInfo = chatItem.chatInfo
var cItem = chatItem.chatItem
if !cInfo.ntfsEnabled {
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
}
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (chatItem.chatId, ntf) : nil
let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
return (chatItem.chatId, ntf)
} else {
return nil
}
@ -620,10 +807,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
return nil
case let .callInvitation(invitation):
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
return (
invitation.contact.id,
useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation))
)
return (invitation.contact.id, .callInvitation(invitation))
case let .ntfMessage(_, connEntity, ntfMessage):
return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil }
case .chatSuspended:
@ -704,15 +888,15 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws {
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
return nil
}
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfMessages(user, connEntity_, expectedMsg_, receivedMsg_) = r, let user = user {
logger.debug("apiGetNtfMessage response ntfMessages: \(receivedMsg_ == nil ? 0 : 1)")
return NtfMessages(user: user, connEntity_: connEntity_, expectedMsg_: expectedMsg_, receivedMsg_: receivedMsg_)
let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfConns(ntfConns) = r {
logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)")
return ntfConns.compactMap { toUserNtfConn($0) }
} else if case let .chatCmdError(_, error) = r {
logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))")
} else {
@ -721,17 +905,33 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
return nil
}
func apiGetConnNtfMessage(connId: String) -> NtfMsgInfo? {
func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? {
if let user = ntfConn.user_ {
return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_)
} else {
return nil
}
}
func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
return nil
}
let r = sendSimpleXCmd(.apiGetConnNtfMessage(connId: connId))
if case let .connNtfMessage(receivedMsg_) = r {
logger.debug("apiGetConnNtfMessage response receivedMsg_: \(receivedMsg_ == nil ? 0 : 1)")
return receivedMsg_
let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds))
if case let .connNtfMessages(receivedMsgs) = r {
logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)")
return receivedMsgs
}
logger.debug("apiGetConnNtfMessages error: \(responseError(r))")
return nil
}
func getConnNtfMessage(connId: String) -> NtfMsgInfo? {
let r_ = apiGetConnNtfMessages(connIds: [connId])
if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil {
return receivedMsg
}
logger.debug("apiGetConnNtfMessage error: \(responseError(r))")
return nil
}
@ -769,13 +969,33 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
throw r
}
struct NtfMessages {
struct UserNtfConn {
var user: User
var connEntity_: ConnectionEntity?
var expectedMsg_: NtfMsgInfo?
var receivedMsg_: NtfMsgInfo?
var ntfsEnabled: Bool {
user.showNotifications && (connEntity_?.ntfsEnabled ?? false)
var defaultBestAttemptNtf: NSENotificationData {
return if !user.showNotifications {
.noNtf
} else if let connEntity = connEntity_ {
switch connEntity {
case let .rcvDirectMsgConnection(_, contact):
contact?.chatSettings.enableNtfs == .all
? .connectionEvent(user, connEntity)
: .noNtf
case let .rcvGroupMsgConnection(_, groupInfo, _):
groupInfo.chatSettings.enableNtfs == .all
? .connectionEvent(user, connEntity)
: .noNtf
case .sndFileConnection: .noNtf
case .rcvFileConnection: .noNtf
case let .userContactConnection(_, userContact):
userContact.groupId == nil
? .connectionEvent(user, connEntity)
: .noNtf
}
} else {
.noNtf
}
}
}

View file

@ -148,6 +148,11 @@
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */; };
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */; };
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
@ -220,15 +225,10 @@
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
E56F46202CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E56F461E2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */; };
E56F46212CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E56F461F2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */; };
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; };
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C42CBA891A00D7A2FA /* libgmpxx.a */; };
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C52CBA891A00D7A2FA /* libgmp.a */; };
E5E997CC2CBA891A00D7A2FA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C72CBA891A00D7A2FA /* libffi.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -491,6 +491,11 @@
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; sourceTree = "<group>"; };
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; sourceTree = "<group>"; };
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
@ -562,8 +567,6 @@
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; };
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
E56F461E2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; sourceTree = "<group>"; };
E56F461F2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@ -616,9 +619,6 @@
E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E5E997C52CBA891A00D7A2FA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E5E997C72CBA891A00D7A2FA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -657,14 +657,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */,
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */,
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E5E997CC2CBA891A00D7A2FA /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
E56F46202CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
E56F46212CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */,
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -741,11 +741,6 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
E5E997C72CBA891A00D7A2FA /* libffi.a */,
E5E997C52CBA891A00D7A2FA /* libgmp.a */,
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */,
E56F461F2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */,
E56F461E2CC2B2E300F1559D /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */,
);
path = Libraries;
sourceTree = "<group>";
@ -817,6 +812,11 @@
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
5C764E5C279C70B7000C6508 /* Libraries */,
643B3B422CCBEB080083A2CF /* libffi.a */,
643B3B442CCBEB080083A2CF /* libgmp.a */,
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */,
5CA059C2279559F40002BEB4 /* Shared */,
5CDCAD462818589900503DA2 /* SimpleX NSE */,
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,

View file

@ -55,8 +55,8 @@ public enum ChatCommand {
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
case apiDeleteToken(token: DeviceToken)
case apiGetNtfMessage(nonce: String, encNtfInfo: String)
case apiGetConnNtfMessage(connId: String)
case apiGetNtfConns(nonce: String, encNtfInfo: String)
case apiGetConnNtfMessages(connIds: [String])
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
@ -214,8 +214,8 @@ public enum ChatCommand {
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
case let .apiGetConnNtfMessage(connId): return "/_ntf conn message \(connId)"
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))"
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
@ -369,8 +369,8 @@ public enum ChatCommand {
case .apiRegisterToken: return "apiRegisterToken"
case .apiVerifyToken: return "apiVerifyToken"
case .apiDeleteToken: return "apiDeleteToken"
case .apiGetNtfMessage: return "apiGetNtfMessage"
case .apiGetConnNtfMessage: return "apiGetConnNtfMessage"
case .apiGetNtfConns: return "apiGetNtfConns"
case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
case .apiNewGroup: return "apiNewGroup"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
@ -682,8 +682,8 @@ public enum ChatResponse: Decodable, Error {
case callInvitations(callInvitations: [RcvCallInvitation])
case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, expectedMsg_: NtfMsgInfo?, receivedMsg_: NtfMsgInfo?)
case connNtfMessage(receivedMsg_: NtfMsgInfo?)
case ntfConns(ntfConns: [NtfConn])
case connNtfMessages(receivedMsgs: [NtfMsgInfo?])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
case contactDisabled(user: UserRef, contact: Contact)
@ -851,8 +851,8 @@ public enum ChatResponse: Decodable, Error {
case .callInvitations: return "callInvitations"
case .ntfTokenStatus: return "ntfTokenStatus"
case .ntfToken: return "ntfToken"
case .ntfMessages: return "ntfMessages"
case .connNtfMessage: return "connNtfMessage"
case .ntfConns: return "ntfConns"
case .connNtfMessages: return "connNtfMessages"
case .ntfMessage: return "ntfMessage"
case .contactConnectionDeleted: return "contactConnectionDeleted"
case .contactDisabled: return "contactDisabled"
@ -1029,8 +1029,8 @@ public enum ChatResponse: Decodable, Error {
case let .callInvitations(invs): return String(describing: invs)
case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
case let .ntfMessages(u, connEntity, expectedMsg_, receivedMsg_): return withUser(u, "connEntity: \(String(describing: connEntity))\nexpectedMsg_: \(String(describing: expectedMsg_))\nreceivedMsg_: \(String(describing: receivedMsg_))")
case let .connNtfMessage(receivedMsg_): return "receivedMsg_: \(String(describing: receivedMsg_))"
case let .ntfConns(ntfConns): return String(describing: ntfConns)
case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))"
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .contactDisabled(u, contact): return withUser(u, String(describing: contact))

View file

@ -2270,16 +2270,13 @@ public enum ConnectionEntity: Decodable, Hashable {
case let .userContactConnection(entityConnection, _): entityConnection
}
}
}
public struct NtfConn: Decodable, Hashable {
public var user_: User?
public var connEntity_: ConnectionEntity?
public var expectedMsg_: NtfMsgInfo?
public var ntfsEnabled: Bool {
switch self {
case let .rcvDirectMsgConnection(_, contact): return contact?.chatSettings.enableNtfs == .all
case let .rcvGroupMsgConnection(_, groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
case .sndFileConnection: return false
case .rcvFileConnection: return false
case let .userContactConnection(_, userContact): return userContact.groupId == nil
}
}
}
public struct NtfMsgInfo: Decodable, Hashable {

View file

@ -15,13 +15,14 @@ public let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
public let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
public let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
public let ntfCategoryConnectionEvent = "NTF_CAT_CONNECTION_EVENT"
public let ntfCategoryManyEvents = "NTF_CAT_MANY_EVENTS"
public let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
public let appNotificationId = "chat.simplex.app.notification"
let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification")
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
return createNotification(
categoryIdentifier: ntfCategoryContactRequest,
@ -34,11 +35,12 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName
),
targetContentIdentifier: nil,
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId]
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId],
badgeCount: badgeCount
)
}
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact) -> UNMutableNotificationContent {
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
return createNotification(
categoryIdentifier: ntfCategoryContactConnected,
@ -51,12 +53,13 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact)
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName
),
targetContentIdentifier: contact.id,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
badgeCount: badgeCount
)
}
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent {
let previewMode = ntfPreviewModeGroupDefault.get()
var title: String
if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir {
@ -69,12 +72,13 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
title: title,
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
targetContentIdentifier: cInfo.id,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
badgeCount: badgeCount
)
}
public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutableNotificationContent {
public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent {
let text = invitation.callType.media == .video
? NSLocalizedString("Incoming video call", comment: "notification")
: NSLocalizedString("Incoming audio call", comment: "notification")
@ -84,11 +88,12 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutabl
title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):",
body: text,
targetContentIdentifier: nil,
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId]
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId],
badgeCount: badgeCount
)
}
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent {
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
var title: String
var body: String? = nil
@ -118,11 +123,12 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit
title: title,
body: body,
targetContentIdentifier: targetContentIdentifier,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
badgeCount: badgeCount
)
}
public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificationContent {
public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent {
var title: String
switch dbStatus {
case .errorNotADatabase:
@ -142,14 +148,16 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificati
}
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: title
title: title,
badgeCount: badgeCount
)
}
public func createAppStoppedNtf() -> UNMutableNotificationContent {
public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent {
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification")
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification"),
badgeCount: badgeCount
)
}
@ -159,8 +167,15 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember
: "#\(groupInfo.displayName) \(groupMember.chatViewName):"
}
public func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
public func createNotification(
categoryIdentifier: String,
title: String,
subtitle: String? = nil,
body: String? = nil,
targetContentIdentifier: String? = nil,
userInfo: [AnyHashable : Any] = [:],
badgeCount: Int
) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.categoryIdentifier = categoryIdentifier
content.title = title
@ -170,6 +185,7 @@ public func createNotification(categoryIdentifier: String, title: String, subtit
content.userInfo = userInfo
// TODO move logic of adding sound here, so it applies to background notifications too
content.sound = .default
content.badge = badgeCount as NSNumber
// content.interruptionLevel = .active
// content.relevanceScore = 0.5 // 0-1
return content

View file

@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 967afaf802d7ea98480eaf280bfc6f35d4d43f05
tag: a8471eed5be93e7c3741aa4742b24193c9a2d6f5
source-repository-package
type: git

View file

@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."967afaf802d7ea98480eaf280bfc6f35d4d43f05" = "0k8m07hxfgn8h8pqrfchqd8490fvv1jf8slw8qjp0vxdpxa84n3i";
"https://github.com/simplex-chat/simplexmq.git"."a8471eed5be93e7c3741aa4742b24193c9a2d6f5" = "093i40api0dp7rvw6f1f3pww3q5iv6mvbj577nlxp3qqcbvyh6fs";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";

View file

@ -204,8 +204,8 @@ _defaultSMPServers =
_defaultNtfServers :: [NtfServer]
_defaultNtfServers =
[ "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion"
-- "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion",
-- "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion"
-- "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion",
-- "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion"
]
maxImageSize :: Integer
@ -1457,26 +1457,31 @@ processChatCommand' vr = \case
CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode)
APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_
APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_
APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do
(NotificationInfo {ntfConnId, ntfMsgMeta = nMsgMeta}, msg) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo
let agentConnId = AgentConnId ntfConnId
user_ <- withStore' (`getUserByAConnId` agentConnId)
connEntity_ <-
pure user_ $>>= \user ->
withStore (\db -> Just <$> getConnectionEntity db vr user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing)
pure
CRNtfMessages
{ user_,
connEntity_,
-- Decrypted ntf meta of the expected message (the one notification was sent for)
expectedMsg_ = expectedMsgInfo <$> nMsgMeta,
-- Info of the first message retrieved by agent using GET
-- (may differ from the expected message due to, for example, coalescing or loss of notifications)
receivedMsg_ = receivedMsgInfo <$> msg
}
ApiGetConnNtfMessage (AgentConnId connId) -> withUser $ \_ -> do
msg <- withAgent $ \a -> getConnectionMessage a connId
pure $ CRConnNtfMessage (receivedMsgInfo <$> msg)
APIGetNtfConns nonce encNtfInfo -> withUser $ \user -> do
ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo
(errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
pure $ CRNtfConns ntfMsgs
where
getMsgConn :: DB.Connection -> NotificationInfo -> IO NtfConn
getMsgConn db NotificationInfo {ntfConnId, ntfMsgMeta = nMsgMeta} = do
let agentConnId = AgentConnId ntfConnId
user_ <- getUserByAConnId db agentConnId
connEntity_ <-
pure user_ $>>= \user ->
eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId)
pure $
NtfConn
{ user_,
connEntity_,
-- Decrypted ntf meta of the expected message (the one notification was sent for)
expectedMsg_ = expectedMsgInfo <$> nMsgMeta
}
ApiGetConnNtfMessages connIds -> withUser $ \_ -> do
let acIds = L.map (\(AgentConnId acId) -> acId) connIds
msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds
let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs
pure $ CRConnNtfMessages ntfMsgs
APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do
cfg@ChatConfig {defaultServers} <- asks config
servers <- withFastStore' (`getProtocolServers` user)
@ -1785,7 +1790,8 @@ processChatCommand' vr = \case
Nothing -> joinNewConn chatV dm
Just (RcvDirectMsgConnection conn@Connection {connId, connStatus, contactConnInitiated} Nothing)
| connStatus == ConnNew && contactConnInitiated -> joinNewConn chatV dm -- own connection link
| connStatus == ConnPrepared -> do -- retrying join after error
| connStatus == ConnPrepared -> do
-- retrying join after error
pcc <- withFastStore $ \db -> getPendingContactConnection db userId connId
joinPreparedConn (aConnId conn) pcc dm
Just ent -> throwChatError $ CECommandError $ "connection exists: " <> show (connEntityInfo ent)
@ -8061,8 +8067,8 @@ chatCommandP =
"/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP),
"/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP),
"/_ntf delete " *> (APIDeleteToken <$> strP),
"/_ntf message " *> (APIGetNtfMessage <$> strP <* A.space <*> strP),
"/_ntf conn message " *> (ApiGetConnNtfMessage <$> strP),
"/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP),
"/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP),
"/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
"/_join #" *> (APIJoinGroup <$> A.decimal),
"/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole),

View file

@ -330,8 +330,8 @@ data ChatCommand
| APIRegisterToken DeviceToken NotificationsMode
| APIVerifyToken DeviceToken C.CbNonce ByteString
| APIDeleteToken DeviceToken
| APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString}
| ApiGetConnNtfMessage {connId :: AgentConnId}
| APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString}
| ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId}
| APIAddMember GroupId ContactId GroupMemberRole
| APIJoinGroup GroupId
| APIMemberRole GroupId GroupMemberId GroupMemberRole
@ -745,8 +745,8 @@ data ChatResponse
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
| CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, expectedMsg_ :: Maybe NtfMsgInfo, receivedMsg_ :: Maybe NtfMsgInfo}
| CRConnNtfMessage {receivedMsg_ :: Maybe NtfMsgInfo}
| CRNtfConns {ntfConns :: [NtfConn]}
| CRConnNtfMessages {receivedMsgs :: NonEmpty (Maybe NtfMsgInfo)}
| CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgAckInfo}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
@ -1010,7 +1010,7 @@ defaultSimpleNetCfg =
smpWebPort = False,
tcpTimeout_ = Nothing,
logTLSErrors = False
}
}
data ContactSubStatus = ContactSubStatus
{ contact :: Contact,
@ -1063,6 +1063,13 @@ instance FromJSON ComposedMessage where
parseJSON invalid =
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
data NtfConn = NtfConn
{ user_ :: Maybe User,
connEntity_ :: Maybe ConnectionEntity,
expectedMsg_ :: Maybe NtfMsgInfo
}
deriving (Show)
data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
deriving (Show)
@ -1535,6 +1542,8 @@ $(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary)
$(JQ.deriveJSON defaultJSON ''NtfMsgInfo)
$(JQ.deriveJSON defaultJSON ''NtfConn)
$(JQ.deriveJSON defaultJSON ''NtfMsgAckInfo)
$(JQ.deriveJSON defaultJSON ''SwitchProgress)

View file

@ -325,8 +325,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
CRNtfMessages {} -> []
CRConnNtfMessage {} -> []
CRNtfConns {} -> []
CRConnNtfMessages {} -> []
CRNtfMessage {} -> []
CRCurrentRemoteHost rhi_ ->
[ maybe