diff --git a/WireDomain/Sources/WireDomain/Assembly.swift b/WireDomain/Sources/WireDomain/Assembly.swift index 12e19d08146..7f0c8d6d4b4 100644 --- a/WireDomain/Sources/WireDomain/Assembly.swift +++ b/WireDomain/Sources/WireDomain/Assembly.swift @@ -62,11 +62,6 @@ public final class Assembly { apiService: apiService ).makeAPI(for: apiVersion) - private lazy var updateEventDecryptor = UpdateEventDecryptor( - proteusService: proteusService, - context: context - ) - // MARK: - Repositories and local stores Init private lazy var userLocalStore = UserLocalStore(context: context) @@ -98,10 +93,6 @@ extension Assembly { self.pushChannel } - Injector.register(UpdateEventDecryptorProtocol.self) { - self.updateEventDecryptor - } - Injector.register(UpdateEventsLocalStoreProtocol.self) { self.updateEventsLocalStore } diff --git a/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift b/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift index ab35a944bb2..df14c9277e6 100644 --- a/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift +++ b/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift @@ -165,7 +165,8 @@ enum Injector { invoker: @escaping ((Arguments) -> Any) -> Any ) -> Service { var resolvedInstance: Service? - var type: Any.Type = if let optionalType = Service.self as? OptionalProtocol.Type { + + let type: Any.Type = if let optionalType = Service.self as? OptionalProtocol.Type { optionalType.wrappedType } else { Service.self diff --git a/WireDomain/Sources/WireDomain/Helpers/ConversationEvent+Notifications.swift b/WireDomain/Sources/WireDomain/Helpers/ConversationEvent+Notifications.swift new file mode 100644 index 00000000000..4cf7566e977 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Helpers/ConversationEvent+Notifications.swift @@ -0,0 +1,117 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +import WireAPI + +extension ConversationEvent { + var senderID: UserID { + switch self { + case let .accessUpdate(conversationAccessUpdateEvent): + conversationAccessUpdateEvent.senderID + case let .codeUpdate(conversationCodeUpdateEvent): + conversationCodeUpdateEvent.senderID + case let .create(conversationCreateEvent): + conversationCreateEvent.senderID + case let .delete(conversationDeleteEvent): + conversationDeleteEvent.senderID + case let .memberJoin(conversationMemberJoinEvent): + conversationMemberJoinEvent.senderID + case let .memberLeave(conversationMemberLeaveEvent): + conversationMemberLeaveEvent.senderID + case let .memberUpdate(conversationMemberUpdateEvent): + conversationMemberUpdateEvent.senderID + case let .messageTimerUpdate(conversationMessageTimerUpdateEvent): + conversationMessageTimerUpdateEvent.senderID + case let .mlsMessageAdd(conversationMLSMessageAddEvent): + conversationMLSMessageAddEvent.senderID + case let .mlsWelcome(conversationMLSWelcomeEvent): + conversationMLSWelcomeEvent.senderID + case let .proteusMessageAdd(conversationProteusMessageAddEvent): + conversationProteusMessageAddEvent.senderID + case let .protocolUpdate(conversationProtocolUpdateEvent): + conversationProtocolUpdateEvent.senderID + case let .receiptModeUpdate(conversationReceiptModeUpdateEvent): + conversationReceiptModeUpdateEvent.senderID + case let .rename(conversationRenameEvent): + conversationRenameEvent.senderID + case let .typing(conversationTypingEvent): + conversationTypingEvent.senderID + } + } + + var conversationID: WireAPI.QualifiedID { + switch self { + case let .accessUpdate(conversationAccessUpdateEvent): + conversationAccessUpdateEvent.conversationID + case let .codeUpdate(conversationCodeUpdateEvent): + conversationCodeUpdateEvent.conversationID + case let .create(conversationCreateEvent): + conversationCreateEvent.conversationID + case let .delete(conversationDeleteEvent): + conversationDeleteEvent.conversationID + case let .memberJoin(conversationMemberJoinEvent): + conversationMemberJoinEvent.conversationID + case let .memberLeave(conversationMemberLeaveEvent): + conversationMemberLeaveEvent.conversationID + case let .memberUpdate(conversationMemberUpdateEvent): + conversationMemberUpdateEvent.conversationID + case let .messageTimerUpdate(conversationMessageTimerUpdateEvent): + conversationMessageTimerUpdateEvent.conversationID + case let .mlsMessageAdd(conversationMLSMessageAddEvent): + conversationMLSMessageAddEvent.conversationID + case let .mlsWelcome(conversationMLSWelcomeEvent): + conversationMLSWelcomeEvent.conversationID + case let .proteusMessageAdd(conversationProteusMessageAddEvent): + conversationProteusMessageAddEvent.conversationID + case let .protocolUpdate(conversationProtocolUpdateEvent): + conversationProtocolUpdateEvent.conversationID + case let .receiptModeUpdate(conversationReceiptModeUpdateEvent): + conversationReceiptModeUpdateEvent.conversationID + case let .rename(conversationRenameEvent): + conversationRenameEvent.conversationID + case let .typing(conversationTypingEvent): + conversationTypingEvent.conversationID + } + } + + var timestamp: Date? { + switch self { + case let .create(conversationCreateEvent): + conversationCreateEvent.timestamp + case let .delete(conversationDeleteEvent): + conversationDeleteEvent.timestamp + case let .memberJoin(conversationMemberJoinEvent): + conversationMemberJoinEvent.timestamp + case let .memberLeave(conversationMemberLeaveEvent): + conversationMemberLeaveEvent.timestamp + case let .memberUpdate(conversationMemberUpdateEvent): + conversationMemberUpdateEvent.timestamp + case let .messageTimerUpdate(conversationMessageTimerUpdateEvent): + conversationMessageTimerUpdateEvent.timestamp + case let .mlsMessageAdd(conversationMLSMessageAddEvent): + conversationMLSMessageAddEvent.timestamp + case let .proteusMessageAdd(conversationProteusMessageAddEvent): + conversationProteusMessageAddEvent.timestamp + case let .rename(conversationRenameEvent): + conversationRenameEvent.timestamp + default: + nil + } + } +} diff --git a/WireDomain/Sources/WireDomain/Helpers/ProtobufMessageHelper.swift b/WireDomain/Sources/WireDomain/Helpers/ProtobufMessageHelper.swift new file mode 100644 index 00000000000..cad9d491b40 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Helpers/ProtobufMessageHelper.swift @@ -0,0 +1,83 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireProtos + +struct ProtobufMessageHelper { + + private init() {} + + static func getProtobufMessage( + from base64Message: String, + externalData: String? = nil + ) -> (GenericMessage, GenericMessage.OneOf_Content)? { + var genericMessage = GenericMessage(withBase64String: base64Message) + + /// If the encrypted payload is bigger than a certain size, an External Message is sent instead of a regular + /// message. + /// See `External` section from https://github.com/wireapp/generic-message-proto + /// See `External messages` section from + /// https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/20545866/Messages + /// + if let externalData, + case let .some(.external(external)) = genericMessage?.content { + + /// Content message is external, we decrypt the external payload + /// and turns it back into a generic non-external content message. + if let decryptedGenericMessage = decryptExternalMessage( + externalData: externalData, + external: external + ) { + genericMessage = decryptedGenericMessage + } else { + return nil + } + } + + guard let genericMessage, let content = genericMessage.content else { + return nil + } + + return (genericMessage, content) + } + + private static func decryptExternalMessage( + externalData: String, + external: External + ) -> GenericMessage? { + let externalData = Data(base64Encoded: externalData) + let externalSha256 = externalData?.zmSHA256Digest() + + guard externalSha256 == external.sha256 else { + return nil + } + + let decryptedData = externalData?.zmDecryptPrefixedPlainTextIV( + key: external.otrKey + ) + + guard let message = GenericMessage( + withBase64String: decryptedData?.base64String() + ) else { + return nil + } + + return message + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Actions/AcceptConnectionNotificationAction.swift b/WireDomain/Sources/WireDomain/Notifications/Actions/AcceptConnectionNotificationAction.swift new file mode 100644 index 00000000000..bf14bfbf44b --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Actions/AcceptConnectionNotificationAction.swift @@ -0,0 +1,42 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +struct AcceptConnectionNotificationAction: NotificationAction { + var identifier: String { + "acceptConnectAction" + } + + var title: String { + "connection.accept" + } + + var isDestructive: Bool { + false + } + + var opensApplication: Bool { + false + } + + var requiresAuthentication: Bool { + false + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Actions/CallbackNotificationAction.swift b/WireDomain/Sources/WireDomain/Notifications/Actions/CallbackNotificationAction.swift new file mode 100644 index 00000000000..28ec25314f7 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Actions/CallbackNotificationAction.swift @@ -0,0 +1,42 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +struct CallbackNotificationAction: NotificationAction { + var identifier: String { + "callbackCallAction" + } + + var title: String { + "call.callback" + } + + var isDestructive: Bool { + false + } + + var opensApplication: Bool { + true + } + + var requiresAuthentication: Bool { + false + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Actions/IgnoreCallNotificationAction.swift b/WireDomain/Sources/WireDomain/Notifications/Actions/IgnoreCallNotificationAction.swift new file mode 100644 index 00000000000..e1506932cde --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Actions/IgnoreCallNotificationAction.swift @@ -0,0 +1,40 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +struct IgnoreCallNotificationAction: NotificationAction { + var identifier: String { + "ignoreCallAction" + } + + var title: String { + "call.ignore" + } + + var isDestructive: Bool { + true + } + + var opensApplication: Bool { + false + } + + var requiresAuthentication: Bool { + false + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Actions/MuteNotificationAction.swift b/WireDomain/Sources/WireDomain/Notifications/Actions/MuteNotificationAction.swift new file mode 100644 index 00000000000..751e7e0a92d --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Actions/MuteNotificationAction.swift @@ -0,0 +1,41 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UserNotifications + +struct MuteNotificationAction: NotificationAction { + var identifier: String { + "conversationMuteAction" + } + + var title: String { + "conversation.mute" + } + + var isDestructive: Bool { + false + } + + var opensApplication: Bool { + false + } + + var requiresAuthentication: Bool { + false + } +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Actions/NotificationAction.swift b/WireDomain/Sources/WireDomain/Notifications/Actions/NotificationAction.swift new file mode 100644 index 00000000000..793b37a82e5 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Actions/NotificationAction.swift @@ -0,0 +1,62 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UserNotifications + +protocol NotificationAction { + + /// The identifier of the action. + var identifier: String { get } + + /// The format for the localized action string. + var title: String { get } + + /// Whether the action deletes content when executed. + var isDestructive: Bool { get } + + /// Whether the action opens the app when executed. + var opensApplication: Bool { get } + + /// Whether the action requires the device to be unlocked before being executed. + var requiresAuthentication: Bool { get } +} + +extension NotificationAction { + func make() -> UNNotificationAction { + var options = UNNotificationActionOptions() + + if isDestructive { + options.insert(.destructive) + } + + if opensApplication { + options.insert(.foreground) + } + + if requiresAuthentication { + options.insert(.authenticationRequired) + } + + /// The representation of the action that can be used with `UserNotifications` API. + return UNNotificationAction( + identifier: identifier, + title: title, + options: options + ) + } +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift new file mode 100644 index 00000000000..e367eda9e12 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift @@ -0,0 +1,141 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UserNotifications +import WireAPI +import WireDataModel + +struct ConversationNotificationBuilder: NotificationBuilder { + + private struct Context { + let senderID: UserID + let conversationID: ConversationID + let isSelfUser: Bool + let isConversationMuted: Bool + let eventTimeStamp: Date? + let lastReadTimestamp: Date? + } + + private let event: ConversationEvent + private let context: Context + + init( + event: ConversationEvent + ) async { + self.event = event + + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() + let userRepository: UserRepositoryProtocol = Injector.resolve() + + let isSelfUser = try? await userRepository.isSelfUser( + id: event.senderID.uuid, + domain: event.senderID.domain + ) + + let conversation = await conversationLocalStore.fetchOrCreateConversation( + id: event.conversationID.uuid, + domain: event.conversationID.domain + ) + + let conversationMutedMessages = await conversationLocalStore.conversationMutedMessageTypesIncludingAvailability( + conversation + ) + + let isConversationMuted = conversationMutedMessages != .none + + let eventTimeStamp = event.timestamp + let lastReadTimestamp = await conversationLocalStore.lastReadServerTimestamp(conversation) + + self.context = Context( + senderID: event.senderID, + conversationID: event.conversationID, + isSelfUser: isSelfUser == true, + isConversationMuted: isConversationMuted, + eventTimeStamp: eventTimeStamp, + lastReadTimestamp: lastReadTimestamp + ) + } + + func buildContent() async -> UNMutableNotificationContent { + let builder: NotificationBuilder + + switch event { + case let .mlsMessageAdd(mlsMessageEvent): + let decryptedMessage = mlsMessageEvent.decryptedMessages.first?.message + + guard let decryptedMessage, + let (genericMessage, _) = ProtobufMessageHelper.getProtobufMessage( + from: decryptedMessage + ) else { + return UNMutableNotificationContent() + } + + builder = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: mlsMessageEvent.conversationID, + senderID: mlsMessageEvent.senderID + ) + + case let .proteusMessageAdd(proteusMessageEvent): + let decryptedMessage = proteusMessageEvent.message.decryptedMessage + let externalEncryptedMessage = proteusMessageEvent.externalData?.encryptedMessage + + guard let decryptedMessage, + let (genericMessage, _) = ProtobufMessageHelper.getProtobufMessage( + from: decryptedMessage, + externalData: externalEncryptedMessage + ) else { + return UNMutableNotificationContent() + } + + builder = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: proteusMessageEvent.conversationID, + senderID: proteusMessageEvent.senderID + ) + + default: // TODO: [WPB-11175] - Generate notifications for other events + return UNMutableNotificationContent() + } + + guard await builder.shouldBuildNotification() else { + return UNMutableNotificationContent() + } + + return await builder.buildContent() + } + + func shouldBuildNotification() async -> Bool { + let isSelfUser = context.isSelfUser + let isConversationMuted = context.isConversationMuted + let eventTimeStamp = context.eventTimeStamp + let lastReadTimestamp = context.lastReadTimestamp + + guard !isSelfUser, + !isConversationMuted, + let eventTimeStamp, + let lastReadTimestamp, + lastReadTimestamp.compare(eventTimeStamp) != .orderedAscending + else { + return false + } + + return true + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift new file mode 100644 index 00000000000..86fd97ddbc9 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift @@ -0,0 +1,378 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireAPI +import WireDataModel + +struct NewMessageNotificationBuilder: NotificationBuilder { + + private enum AssetType { + case image + case video + case audio + case fileUpload + } + + struct Context { + let senderName: String? + let conversationName: String? + let isGroupConversation: Bool + let teamName: String? + let isMessageSilenced: Bool + let conversationID: WireAPI.QualifiedID + let senderID: UUID + let selfUserID: UUID + let hidesNotificationContent: Bool + } + + private let message: GenericMessage + private let context: Context + + init( + message: GenericMessage, + conversationID: WireAPI.QualifiedID, + senderID: UserID + ) async { + self.message = message + + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() + let userLocalStore: UserLocalStoreProtocol = Injector.resolve() + + let conversation = await conversationLocalStore.fetchOrCreateConversation( + id: conversationID.uuid, + domain: conversationID.domain + ) + + let sender = await userLocalStore.fetchOrCreateUser( + id: senderID.uuid, + domain: senderID.domain + ) + + let senderName = await userLocalStore.name(for: sender) + let conversationName = await conversationLocalStore.name(for: conversation) + let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation) + let selfUser = await userLocalStore.fetchSelfUser() + let teamName = await userLocalStore.teamName(for: selfUser) + let isMessageSilenced = await conversationLocalStore.isMessageSilenced( + message, + senderID: senderID.uuid, + conversation: conversation + ) + let selfUserID = await userLocalStore.id(for: selfUser) + let shouldHideNotification = await conversationLocalStore.shouldHideNotification() + + self.context = Context( + senderName: senderName, + conversationName: conversationName, + isGroupConversation: isGroupConversation, + teamName: teamName, + isMessageSilenced: isMessageSilenced, + conversationID: conversationID, + senderID: senderID.uuid, + selfUserID: selfUserID, + hidesNotificationContent: shouldHideNotification + ) + } + + func shouldBuildNotification() async -> Bool { + !context.isMessageSilenced + } + + func buildContent() async -> UNMutableNotificationContent { + guard !context.hidesNotificationContent else { + return buildHiddenNotification() + } + + switch message.content { + case .location: + return buildLocationNotification() + case .knock: + return buildPingNotification() + case .image: + return buildAssetNotification(ofType: .image) + case let .ephemeral(ephemeral): + return await buildEphemeralNotification(ephemeral: ephemeral) + case let .text(text): + return await buildTextNotification(text) + case let .composite(composite): + let text = composite.items.compactMap(\.text).first + return await buildTextNotification(text) + case let .asset(assetData): + switch assetData.original.metaData { + case .audio: + return buildAssetNotification(ofType: .audio) + case .video: + return buildAssetNotification(ofType: .video) + case .image: + return buildAssetNotification(ofType: .image) + default: + return buildAssetNotification(ofType: .fileUpload) + } + case .hidden: + return buildHiddenNotification() + default: + return UNMutableNotificationContent() + } + } + + // MARK: - Build notifications + + private func buildAssetNotification(ofType assetType: AssetType) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let isGroupConversation = context.isGroupConversation + let senderName = context.senderName + + if let title = makeTitle() { + content.title = title + } + + let body: NotificationBody = switch assetType { + case .image: + .newMessage( + .sharedPicture(senderName: isGroupConversation ? senderName : nil) + ) + case .video: + .newMessage( + .sharedVideo(senderName: isGroupConversation ? senderName : nil) + ) + case .audio: + .newMessage( + .sharedAudio(senderName: isGroupConversation ? senderName : nil) + ) + case .fileUpload: + .newMessage( + .sharedFile(senderName: isGroupConversation ? senderName : nil) + ) + } + + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound() + content.userInfo = makeUserInfo() + content.threadIdentifier = context.conversationID.uuid.transportString() + + return content + } + + private func buildPingNotification() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let senderName = context.senderName + + if let title = makeTitle() { + content.title = title + } + + let body = NotificationBody.newMessage( + .ping(senderName: context.isGroupConversation ? senderName : nil) + ) + + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound(type: .ping) + content.userInfo = makeUserInfo() + content.threadIdentifier = context.conversationID.uuid.transportString() + + return content + } + + private func buildHiddenNotification() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + + // No title for hidden message, only a body. + let body: NotificationBody = .newMessage(.hidden) + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound() + content.userInfo = makeUserInfo() + content.threadIdentifier = context.conversationID.uuid.transportString() + + return content + } + + private func buildTextNotification(_ text: Text?) async -> UNMutableNotificationContent { + guard let textMessageData = text else { + return UNMutableNotificationContent() + } + + let text = textMessageData.content.removingExtremeCombiningCharacters + + guard !text.isEmpty else { + return UNMutableNotificationContent() + } + + let messageLocalStore: MessageLocalStoreProtocol = Injector.resolve() + let quotedMessageId = UUID(uuidString: textMessageData.quote.quotedMessageID) + let quotedMessage = await messageLocalStore.fetchMessage( + id: quotedMessageId, + conversationID: context.conversationID.uuid, + conversationDomain: context.conversationID.domain + ) + + let isMention = await messageLocalStore.isMessageMentioningSelf(text: textMessageData) + let isReply = await messageLocalStore.isMessageQuotingSelf(quotedMessage: quotedMessage) + let senderName = context.senderName + + let content = UNMutableNotificationContent() + + if let title = makeTitle() { + content.title = title + } + + let format: NotificationBody.MessageBodyFormat = if isMention { + .textWithMention(content: text, senderName: senderName) + } else if isReply { + .textWithReply(content: text, senderName: senderName) + } else { + .text(content: text, senderName: senderName) + } + + let body = NotificationBody.newMessage( + format + ) + + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound() + content.userInfo = makeUserInfo() + content.threadIdentifier = context.conversationID.uuid.transportString() + + return content + } + + private func buildLocationNotification() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let isGroupConversation = context.isGroupConversation + let senderName = context.senderName + + if let title = makeTitle() { + content.title = title + } + + let body = NotificationBody.newMessage( + .sharedLocation(senderName: isGroupConversation ? senderName : nil) + ) + + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound() + content.userInfo = makeUserInfo() + content.threadIdentifier = context.conversationID.uuid.transportString() + + return content + } + + private func buildEphemeralNotification( + ephemeral: Ephemeral + ) async -> UNMutableNotificationContent { + let isMention: Bool + let isReply: Bool + + if ephemeral.hasText { + let textMessageData = ephemeral.text + let messageLocalStore: MessageLocalStoreProtocol = Injector.resolve() + let quotedMessageId = UUID(uuidString: textMessageData.quote.quotedMessageID) + let quotedMessage = await messageLocalStore.fetchMessage( + id: quotedMessageId, + conversationID: context.conversationID.uuid, + conversationDomain: context.conversationID.domain + ) + + isMention = await messageLocalStore.isMessageMentioningSelf(text: textMessageData) + isReply = await messageLocalStore.isMessageQuotingSelf(quotedMessage: quotedMessage) + + } else { + isMention = false + isReply = false + } + + let content = UNMutableNotificationContent() + + let format: NotificationBody.MessageBodyFormat = if isMention { + .mentionedWithUnknownSender + } else if isReply { + .repliedWithUnknownSender + } else { + .sentWithUnknownSender + } + + let body = NotificationBody.newMessage( + format + ) + + // No thread identifier for ephemeral messages as we only want to group non ephemeral ones. + content.body = body.make() + content.categoryIdentifier = makeCategory() + content.sound = makeSound() + content.userInfo = makeUserInfo() + + return content + } + + // MARK: - Helpers + + private func makeTitle( + ) -> String? { + let isGroupConversation = context.isGroupConversation + let teamName = context.teamName + let conversationName = context.conversationName + let senderName = context.senderName + + guard let conversationName, let senderName else { + return nil + } + + let format: NotificationTitle.MessageTitleFormat = if isGroupConversation { + if let teamName { + .conversationInTeam(conversation: conversationName, team: teamName) + } else { + .conversation(conversation: conversationName) + } + } else { + if let teamName { + .senderInTeam(sender: senderName, team: teamName) + } else { + .sender(sender: senderName) + } + } + + return NotificationTitle + .newMessage(format) + .make() + } + + private func makeSound(type: NotificationSound = .default) -> UNNotificationSound { + let notificationSoundName = UNNotificationSoundName(type.rawValue) + return UNNotificationSound(named: notificationSoundName) + } + + private func makeCategory() -> String { + let category = NotificationCategory.unmutedConversation + return category.rawValue + } + + private func makeUserInfo() -> [AnyHashable: Any] { + var userInfo: [AnyHashable: Any] = [:] + + userInfo["selfUserIDString"] = context.selfUserID + userInfo["senderIDString"] = context.senderID + userInfo["conversationIDString"] = context.conversationID.uuid + + return userInfo + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/NotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/NotificationBuilder.swift new file mode 100644 index 00000000000..fcb4855ceb8 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/NotificationBuilder.swift @@ -0,0 +1,24 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UserNotifications + +protocol NotificationBuilder { + func shouldBuildNotification() async -> Bool + func buildContent() async -> UNMutableNotificationContent +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationBodyComposer.swift b/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationBodyComposer.swift new file mode 100644 index 00000000000..d7c7a8b94af --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationBodyComposer.swift @@ -0,0 +1,55 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +struct NewMessageNotificationBodyComposer { + let format: NotificationBody.MessageBodyFormat + + // TODO: [WPB-15153] - Localize strings + func make() -> String { + switch format { + case .sentWithUnknownSender: + "Someone sent a message" + case .mentionedWithUnknownSender: + "Someone mentioned you" + case .repliedWithUnknownSender: + "Someone replied to you" + case let .text(content, senderName): + senderName != nil ? "\(senderName!): \(content)" : content + case let .textWithMention(content, senderName): + senderName != nil ? "Mention from \(senderName!): \(content)" : "Mention: \(content)" + case let .textWithReply(content, senderName): + senderName != nil ? "Reply from \(senderName!): \(content)" : "Reply: \(content)" + case let .sharedPicture(senderName): + senderName != nil ? "\(senderName!) shared a picture" : "Shared a picture" + case let .sharedVideo(senderName): + senderName != nil ? "\(senderName!) shared a video" : "Shared a video" + case let .sharedAudio(senderName): + senderName != nil ? "\(senderName!) shared an audio message" : "Shared an audio message" + case let .sharedFile(senderName): + senderName != nil ? "\(senderName!) shared a file" : "Shared a file" + case let .sharedLocation(senderName): + senderName != nil ? "\(senderName!) shared a location" : "Shared a location" + case let .ping(senderName): + senderName != nil ? "\(senderName!) pinged you" : "Pinged you" + case .hidden: + "New message" + } + } +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationTitleComposer.swift b/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationTitleComposer.swift new file mode 100644 index 00000000000..2b0d221f76a --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Composers/NewMessageNotificationTitleComposer.swift @@ -0,0 +1,38 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +struct NewMessageNotificationTitleComposer { + let format: NotificationTitle.MessageTitleFormat + + // TODO: [WPB-15153] - Localize strings + func make() -> String { + switch format { + case let .sender(sender): + "\(sender)" + case let .senderInTeam(sender, team): + "\(sender) in \(team)" + case let .conversation(conversation): + "\(conversation)" + case let .conversationInTeam(conversation, team): + "\(conversation) in \(team)" + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift new file mode 100644 index 00000000000..6b87b419216 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift @@ -0,0 +1,74 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +enum NotificationBody { + + case newMessage(MessageBodyFormat) + case bundled(messagesCount: Int) + + func make() -> String { + switch self { + case let .newMessage(messageBodyFormat): + let newMessageBodyComposer = NewMessageNotificationBodyComposer( + format: messageBodyFormat + ) + + return newMessageBodyComposer.make() + + case let .bundled(count): + return "\(count) new messages." + } + } + +} + +extension NotificationBody { + + /// The expected formats for the body of a new message notification. + enum MessageBodyFormat { + /// `Someone sent a message` + case sentWithUnknownSender + /// `Someone mentioned you` + case mentionedWithUnknownSender + /// `Someone replied to you` + case repliedWithUnknownSender + /// `[sender name]: [text]` or `[text]` is sender is nil. + case text(content: String, senderName: String?) + /// `Mention from [sender name]: [text]` or `Mention: [text]` is sender is nil. + case textWithMention(content: String, senderName: String?) + /// `Reply from [sender name]: [text]` or `Reply: [text]` if sender is nil. + case textWithReply(content: String, senderName: String?) + /// `[sender name] shared a picture` or `Shared a picture` if sender is nil. + case sharedPicture(senderName: String?) + /// `[sender name] shared a video` or `Shared a video` if sender is nil. + case sharedVideo(senderName: String?) + /// `[sender name] shared an audio message` or `Shared an audio message` if sender is nil. + case sharedAudio(senderName: String?) + /// `[sender name] shared a file` or `Shared a file` if sender is nil. + case sharedFile(senderName: String?) + /// `[sender name] shared a location` or `Shared a location` if sender is nil. + case sharedLocation(senderName: String?) + /// `[sender name] pinged you` or `Pinged you` if sender is nil + case ping(senderName: String?) + /// `New message` + case hidden + } + +} diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationCategory.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationCategory.swift new file mode 100644 index 00000000000..0dba211dac1 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationCategory.swift @@ -0,0 +1,63 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UserNotifications + +/// Categories to which push notifications belong. +public enum NotificationCategory: String, CaseIterable { + + case nonActionable + case unmutedConversation + case incomingCall + case missedCall + case incomingConnectionRequest + + /// Available actions for each category + private var actions: [NotificationAction] { + switch self { + case .nonActionable: + [] + case .unmutedConversation: + [MuteNotificationAction()] + case .incomingCall: + [IgnoreCallNotificationAction()] + case .missedCall: + [CallbackNotificationAction()] + case .incomingConnectionRequest: + [AcceptConnectionNotificationAction()] + } + } + + private func make() -> UNNotificationCategory { + let userActions = actions.map { $0.make() } + + return UNNotificationCategory( + identifier: rawValue, + actions: userActions, + intentIdentifiers: [], + options: [] + ) + } +} + +extension NotificationCategory { + static var allCategories: Set { + let categories = NotificationCategory.allCases.map { $0.make() } + return Set(categories) + } +} diff --git a/WireDomain/Sources/WireDomain/NotificationService/NotificationPayload.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationPayload.swift similarity index 100% rename from WireDomain/Sources/WireDomain/NotificationService/NotificationPayload.swift rename to WireDomain/Sources/WireDomain/Notifications/NotificationPayload.swift diff --git a/WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift similarity index 98% rename from WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift rename to WireDomain/Sources/WireDomain/Notifications/NotificationService.swift index bb681a87818..7bc4d02d28d 100644 --- a/WireDomain/Sources/WireDomain/NotificationService/NotificationService.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift @@ -105,7 +105,7 @@ final class NotificationService: UNNotificationServiceExtension { let updateEventsRepository = UpdateEventsRepository( userID: userID, selfClientID: selfClientID, - // these were already initialized, resolving them + // these dependencies were already initialized, resolving them updateEventsAPI: Injector.resolve(), pushChannel: Injector.resolve(), updateEventDecryptor: Injector.resolve(), @@ -120,7 +120,7 @@ final class NotificationService: UNNotificationServiceExtension { } private func finishWithNotification(content: UNNotificationContent) { - // TODO: [WPB-11175] + contentHandler?(content) } private func finishWithEmptyNotification() { diff --git a/WireDomain/Sources/WireDomain/NotificationService/NotificationSession.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift similarity index 62% rename from WireDomain/Sources/WireDomain/NotificationService/NotificationSession.swift rename to WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift index 7760f8c505d..77981fe8ac4 100644 --- a/WireDomain/Sources/WireDomain/NotificationService/NotificationSession.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift @@ -44,7 +44,16 @@ final class NotificationSession { self.subscription = updateEventsRepository.observePendingEvents() .collect() // Collects all the events batches. .map { $0.flatMap { $0 } } - .map(generateNotificationContent) + .map { events in + // Uses a Future to bridge between Combine and async/await + Future { [self] promise in + Task { + let notification = await generateNotificationContent(for: events) + promise(.success(notification)) + } + } + } + .switchToLatest() .sink(receiveValue: onNotificationContent) } @@ -74,25 +83,48 @@ final class NotificationSession { private func generateNotificationContent( for events: [UpdateEvent] - ) -> UNMutableNotificationContent { - // TODO: [WPB-11175] - Generate UNNotificationContent from update events + ) async -> UNMutableNotificationContent { + + var notifications: [UNMutableNotificationContent] = [] + for event in events { + var notificationBuilder: NotificationBuilder + switch event { case let .conversation(conversationEvent): - break + notificationBuilder = await ConversationNotificationBuilder( + event: conversationEvent + ) + // TODO: [WPB-10218] - Generate notif for other update events case let .featureConfig(featureConfigEvent): - break + continue case let .federation(federationEvent): - break + continue case let .user(userEvent): - break + continue case let .team(teamEvent): - break + continue case let .unknown(eventType): - break + continue + } + + guard await notificationBuilder.shouldBuildNotification() else { + continue } + + let notificationContent = await notificationBuilder.buildContent() + notifications.append(notificationContent) + } + + var notification = UNMutableNotificationContent() + + if notifications.count > 1 { + let body = NotificationBody.bundled(messagesCount: notifications.count) + notification.body = body.make() + } else if let singleNotification = notifications.first { + notification = singleNotification } - return UNMutableNotificationContent() + return notification } } diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationSound.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationSound.swift new file mode 100644 index 00000000000..94216ff8d94 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationSound.swift @@ -0,0 +1,26 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +/// Push notification sounds. +enum NotificationSound: String { + case call = "ringing_from_them_long.caf" + case ping = "ping_from_them.caf" + case `default` +} diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationTitle.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationTitle.swift new file mode 100644 index 00000000000..421ad6f903c --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationTitle.swift @@ -0,0 +1,52 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +enum NotificationTitle { + + case newMessage(MessageTitleFormat) + + func make() -> String { + switch self { + case let .newMessage(messageFormat): + let newMessageTitleComposer = NewMessageNotificationTitleComposer( + format: messageFormat + ) + + return newMessageTitleComposer.make() + } + } + +} + +extension NotificationTitle { + + /// The expected formats for the title of a new message notification. + enum MessageTitleFormat { + /// `[sender name]` + case sender(sender: String) + /// `[sender name] in [team name]` + case senderInTeam(sender: String, team: String) + /// `[conversation name]` + case conversation(conversation: String) + /// `[conversation name] in [team name]` + case conversationInTeam(conversation: String, team: String) + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift index b560080206f..217705b4d27 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift @@ -191,6 +191,14 @@ public protocol ConversationLocalStoreProtocol { for conversation: ZMConversation ) async + /// The display name for a given conversation. + /// - parameter conversation: The conversation to fetch the name for. + /// - returns: The conversation display name. + + func name( + for conversation: ZMConversation + ) async -> String? + /// Indicates whether a given conversation is read-only. /// - parameter conversation: The conversation to check the flag for. /// - returns: Whether the conversation is read-only. @@ -308,6 +316,10 @@ public protocol ConversationLocalStoreProtocol { date: Date ) async + func lastReadServerTimestamp( + _ conversation: ZMConversation + ) async -> Date? + /// Updates last read message timestamp. /// - Parameters: /// - lastReadMessage: The last read message protobuf object. @@ -396,6 +408,17 @@ public protocol ConversationLocalStoreProtocol { conversation: ZMConversation ) async -> WireDataModel.QualifiedID? + func conversationMutedMessageTypesIncludingAvailability( + _ conversation: ZMConversation + ) async -> MutedMessageTypes + + func isMessageSilenced( + _ message: GenericMessage, + senderID: UUID?, + conversation: ZMConversation + ) async -> Bool + + func shouldHideNotification() async -> Bool } public final class ConversationLocalStore: ConversationLocalStoreProtocol { @@ -876,6 +899,27 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { } } + public func isMessageSilenced( + _ message: GenericMessage, + senderID: UUID?, + conversation: ZMConversation + ) async -> Bool { + await context.perform { + conversation.isMessageSilenced(message, senderID: senderID) + } + } + + public func shouldHideNotification() async -> Bool { + await context.perform { [context] in + let ZMShouldHideNotificationContentKey = "ZMShouldHideNotificationContentKey" + let value = context.persistentStoreMetadata( + forKey: ZMShouldHideNotificationContentKey + ) as? NSNumber + + return value?.boolValue ?? false + } + } + public func commitPendingProposals( conversation: ZMConversation, date: Date, @@ -933,6 +977,23 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { } } + public func conversationMutedMessageTypesIncludingAvailability( + _ conversation: ZMConversation + ) async -> MutedMessageTypes { + await context.perform { [context] in + let selfUser = ZMUser.selfUser(in: context) + return selfUser.mutedMessagesTypes.union(conversation.mutedMessageTypes) + } + } + + public func lastReadServerTimestamp( + _ conversation: ZMConversation + ) async -> Date? { + await context.perform { + conversation.lastReadServerTimeStamp + } + } + public func conversationMessageDestructionTimeout( _ conversation: ZMConversation ) async -> MessageDestructionTimeoutValue { @@ -978,6 +1039,14 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { } } + public func name( + for conversation: ZMConversation + ) async -> String? { + await context.perform { + conversation.displayName + } + } + public func removeParticipantFromAllGroupConversations( participantID: UUID, participantDomain: String?, diff --git a/WireDomain/Sources/WireDomain/Repositories/Message/MessageLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Message/MessageLocalStore.swift index 2f8aafb0813..e665b6b2df0 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Message/MessageLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Message/MessageLocalStore.swift @@ -189,6 +189,20 @@ public protocol MessageLocalStoreProtocol { date: Date ) async + func fetchMessage( + id: UUID?, + conversationID: UUID, + conversationDomain: String? + ) async -> ZMOTRMessage? + + func isMessageMentioningSelf( + text: Text + ) async -> Bool + + func isMessageQuotingSelf( + quotedMessage: ZMOTRMessage? + ) async -> Bool + } public final class MessageLocalStore: MessageLocalStoreProtocol { @@ -224,6 +238,47 @@ public final class MessageLocalStore: MessageLocalStoreProtocol { // MARK: - Public + public func fetchMessage( + id: UUID?, + conversationID: UUID, + conversationDomain: String? + ) async -> ZMOTRMessage? { + + guard let conversation = await conversationLocalStore.fetchConversation( + id: conversationID, + domain: conversationDomain + ) else { + return nil + } + + return await context.perform { [context] in + ZMOTRMessage.fetch( + withNonce: id, + for: conversation, + in: context + ) + } + + } + + public func isMessageMentioningSelf( + text: Text + ) async -> Bool { + let selfUser = await userLocalStore.fetchSelfUser() + + return await context.perform { + text.mentions.any { $0.userID.uppercased() == selfUser.remoteIdentifier.uuidString } + } + } + + public func isMessageQuotingSelf( + quotedMessage: ZMOTRMessage? + ) async -> Bool { + await context.perform { + quotedMessage?.sender?.isSelfUser ?? false + } + } + public func addSystemMessage( messageType: SystemMessageType, conversationID: UUID, diff --git a/WireDomain/Sources/WireDomain/Repositories/User/UserLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/User/UserLocalStore.swift index 4e280a98fdd..0c213aea1f5 100644 --- a/WireDomain/Sources/WireDomain/Repositories/User/UserLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/User/UserLocalStore.swift @@ -137,6 +137,30 @@ public protocol UserLocalStoreProtocol { /// - returns: the user ID and the client ID. func selfUserInfo() async -> (id: UUID, clientId: String?) + + /// The name of a given user. + /// - Parameter user: The user to fetch the name for. + /// - returns: The user name. + + func name( + for user: ZMUser + ) async -> String? + + /// The team name of a given user. + /// - Parameter user: The user to fetch the team for. + /// - returns: The team name if any. + + func teamName( + for user: ZMUser + ) async -> String? + + /// The identifier for a given user + /// - parameter user: The user to get the ID for. + /// - returns: The user UUID. + + func id( + for user: ZMUser + ) async -> UUID } public final class UserLocalStore: UserLocalStoreProtocol { @@ -256,6 +280,30 @@ public final class UserLocalStore: UserLocalStoreProtocol { ) } + public func name( + for user: ZMUser + ) async -> String? { + await context.perform { + user.name + } + } + + public func teamName( + for user: ZMUser + ) async -> String? { + await context.perform { + user.teamName + } + } + + public func id( + for user: ZMUser + ) async -> UUID { + await context.perform { + user.remoteIdentifier + } + } + public func addSelfLegalHoldRequest( userID: UUID, clientID: String, diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index c501763fd09..089e4f5c3a2 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -505,6 +505,24 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol await mock(hasReadReceiptsEnabled, conversation) } + // MARK: - name + + public var nameFor_Invocations: [ZMConversation] = [] + public var nameFor_MockMethod: ((ZMConversation) async -> String?)? + public var nameFor_MockValue: String?? + + public func name(for conversation: ZMConversation) async -> String? { + nameFor_Invocations.append(conversation) + + if let mock = nameFor_MockMethod { + return await mock(conversation) + } else if let mock = nameFor_MockValue { + return mock + } else { + fatalError("no mock for `nameFor`") + } + } + // MARK: - isConversationForcedReadOnly public var isConversationForcedReadOnly_Invocations: [ZMConversation] = [] @@ -718,6 +736,24 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol await mock(participantID, participantDomain, conversation, date) } + // MARK: - lastReadServerTimestamp + + public var lastReadServerTimestamp_Invocations: [ZMConversation] = [] + public var lastReadServerTimestamp_MockMethod: ((ZMConversation) async -> Date?)? + public var lastReadServerTimestamp_MockValue: Date?? + + public func lastReadServerTimestamp(_ conversation: ZMConversation) async -> Date? { + lastReadServerTimestamp_Invocations.append(conversation) + + if let mock = lastReadServerTimestamp_MockMethod { + return await mock(conversation) + } else if let mock = lastReadServerTimestamp_MockValue { + return mock + } else { + fatalError("no mock for `lastReadServerTimestamp`") + } + } + // MARK: - updateLastReadMessageTimestamp public var updateLastReadMessageTimestampIn_Invocations: [(lastReadMessage: LastRead, conversation: ZMConversation)] = [] @@ -859,6 +895,60 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol } } + // MARK: - conversationMutedMessageTypesIncludingAvailability + + public var conversationMutedMessageTypesIncludingAvailability_Invocations: [ZMConversation] = [] + public var conversationMutedMessageTypesIncludingAvailability_MockMethod: ((ZMConversation) async -> MutedMessageTypes)? + public var conversationMutedMessageTypesIncludingAvailability_MockValue: MutedMessageTypes? + + public func conversationMutedMessageTypesIncludingAvailability(_ conversation: ZMConversation) async -> MutedMessageTypes { + conversationMutedMessageTypesIncludingAvailability_Invocations.append(conversation) + + if let mock = conversationMutedMessageTypesIncludingAvailability_MockMethod { + return await mock(conversation) + } else if let mock = conversationMutedMessageTypesIncludingAvailability_MockValue { + return mock + } else { + fatalError("no mock for `conversationMutedMessageTypesIncludingAvailability`") + } + } + + // MARK: - isMessageSilenced + + public var isMessageSilencedSenderIDConversation_Invocations: [(message: GenericMessage, senderID: UUID?, conversation: ZMConversation)] = [] + public var isMessageSilencedSenderIDConversation_MockMethod: ((GenericMessage, UUID?, ZMConversation) async -> Bool)? + public var isMessageSilencedSenderIDConversation_MockValue: Bool? + + public func isMessageSilenced(_ message: GenericMessage, senderID: UUID?, conversation: ZMConversation) async -> Bool { + isMessageSilencedSenderIDConversation_Invocations.append((message: message, senderID: senderID, conversation: conversation)) + + if let mock = isMessageSilencedSenderIDConversation_MockMethod { + return await mock(message, senderID, conversation) + } else if let mock = isMessageSilencedSenderIDConversation_MockValue { + return mock + } else { + fatalError("no mock for `isMessageSilencedSenderIDConversation`") + } + } + + // MARK: - shouldHideNotification + + public var shouldHideNotification_Invocations: [Void] = [] + public var shouldHideNotification_MockMethod: (() async -> Bool)? + public var shouldHideNotification_MockValue: Bool? + + public func shouldHideNotification() async -> Bool { + shouldHideNotification_Invocations.append(()) + + if let mock = shouldHideNotification_MockMethod { + return await mock() + } else if let mock = shouldHideNotification_MockValue { + return mock + } else { + fatalError("no mock for `shouldHideNotification`") + } + } + } public class MockConversationProtobufMessageProcessorProtocol: ConversationProtobufMessageProcessorProtocol { @@ -1401,6 +1491,60 @@ public class MockMessageLocalStoreProtocol: MessageLocalStoreProtocol { await mock(messageEdit, conversation, senderID, genericMessage, date) } + // MARK: - fetchMessage + + public var fetchMessageIdConversationIDConversationDomain_Invocations: [(id: UUID?, conversationID: UUID, conversationDomain: String?)] = [] + public var fetchMessageIdConversationIDConversationDomain_MockMethod: ((UUID?, UUID, String?) async -> ZMOTRMessage?)? + public var fetchMessageIdConversationIDConversationDomain_MockValue: ZMOTRMessage?? + + public func fetchMessage(id: UUID?, conversationID: UUID, conversationDomain: String?) async -> ZMOTRMessage? { + fetchMessageIdConversationIDConversationDomain_Invocations.append((id: id, conversationID: conversationID, conversationDomain: conversationDomain)) + + if let mock = fetchMessageIdConversationIDConversationDomain_MockMethod { + return await mock(id, conversationID, conversationDomain) + } else if let mock = fetchMessageIdConversationIDConversationDomain_MockValue { + return mock + } else { + fatalError("no mock for `fetchMessageIdConversationIDConversationDomain`") + } + } + + // MARK: - isMessageMentioningSelf + + public var isMessageMentioningSelfText_Invocations: [Text] = [] + public var isMessageMentioningSelfText_MockMethod: ((Text) async -> Bool)? + public var isMessageMentioningSelfText_MockValue: Bool? + + public func isMessageMentioningSelf(text: Text) async -> Bool { + isMessageMentioningSelfText_Invocations.append(text) + + if let mock = isMessageMentioningSelfText_MockMethod { + return await mock(text) + } else if let mock = isMessageMentioningSelfText_MockValue { + return mock + } else { + fatalError("no mock for `isMessageMentioningSelfText`") + } + } + + // MARK: - isMessageQuotingSelf + + public var isMessageQuotingSelfQuotedMessage_Invocations: [ZMOTRMessage?] = [] + public var isMessageQuotingSelfQuotedMessage_MockMethod: ((ZMOTRMessage?) async -> Bool)? + public var isMessageQuotingSelfQuotedMessage_MockValue: Bool? + + public func isMessageQuotingSelf(quotedMessage: ZMOTRMessage?) async -> Bool { + isMessageQuotingSelfQuotedMessage_Invocations.append(quotedMessage) + + if let mock = isMessageQuotingSelfQuotedMessage_MockMethod { + return await mock(quotedMessage) + } else if let mock = isMessageQuotingSelfQuotedMessage_MockValue { + return mock + } else { + fatalError("no mock for `isMessageQuotingSelfQuotedMessage`") + } + } + } public class MockMessageRepositoryProtocol: MessageRepositoryProtocol { @@ -2724,6 +2868,60 @@ public class MockUserLocalStoreProtocol: UserLocalStoreProtocol { } } + // MARK: - name + + public var nameFor_Invocations: [ZMUser] = [] + public var nameFor_MockMethod: ((ZMUser) async -> String?)? + public var nameFor_MockValue: String?? + + public func name(for user: ZMUser) async -> String? { + nameFor_Invocations.append(user) + + if let mock = nameFor_MockMethod { + return await mock(user) + } else if let mock = nameFor_MockValue { + return mock + } else { + fatalError("no mock for `nameFor`") + } + } + + // MARK: - teamName + + public var teamNameFor_Invocations: [ZMUser] = [] + public var teamNameFor_MockMethod: ((ZMUser) async -> String?)? + public var teamNameFor_MockValue: String?? + + public func teamName(for user: ZMUser) async -> String? { + teamNameFor_Invocations.append(user) + + if let mock = teamNameFor_MockMethod { + return await mock(user) + } else if let mock = teamNameFor_MockValue { + return mock + } else { + fatalError("no mock for `teamNameFor`") + } + } + + // MARK: - id + + public var idFor_Invocations: [ZMUser] = [] + public var idFor_MockMethod: ((ZMUser) async -> UUID)? + public var idFor_MockValue: UUID? + + public func id(for user: ZMUser) async -> UUID { + idFor_Invocations.append(user) + + if let mock = idFor_MockMethod { + return await mock(user) + } else if let mock = idFor_MockValue { + return mock + } else { + fatalError("no mock for `idFor`") + } + } + } public class MockUserRepositoryProtocol: UserRepositoryProtocol { diff --git a/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift new file mode 100644 index 00000000000..6c02b42e7ed --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift @@ -0,0 +1,338 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireAPISupport +import WireDataModel +import WireDataModelSupport +import WireTestingPackage +import XCTest +@testable import WireAPI +@testable import WireDomain +@testable import WireDomainSupport + +final class NewMessageNotificationBuilderTests: XCTestCase { + private var sut: NewMessageNotificationBuilder! + private var conversationLocalStore: MockConversationLocalStoreProtocol! + private var messageLocalStore: MockMessageLocalStoreProtocol! + private var userLocalStore: MockUserLocalStoreProtocol! + + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + conversationLocalStore = MockConversationLocalStoreProtocol() + userLocalStore = MockUserLocalStoreProtocol() + messageLocalStore = MockMessageLocalStoreProtocol() + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + registerDependencies() + } + + override func tearDown() async throws { + stack = nil + sut = nil + conversationLocalStore = nil + messageLocalStore = nil + userLocalStore = nil + try coreDataStackHelper.cleanupDirectory() + modelHelper = nil + coreDataStackHelper = nil + } + + private func registerDependencies() { + Injector.register(ConversationLocalStoreProtocol.self) { + self.conversationLocalStore + } + + Injector.register(UserLocalStoreProtocol.self) { + self.userLocalStore + } + + Injector.register(MessageLocalStoreProtocol.self) { + self.messageLocalStore + } + } + + func testGenerateNewMessageNotifications_Is_Group_Conversation_And_Is_Team_User() async throws { + + // Mock + + let isGroup = true + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let messagesCapable = getAllMessagesCapable() + + for messageCapable in messagesCapable { + let genericMessage = GenericMessage(content: messageCapable) + sut = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let notification = await sut.buildContent() + + try await internalTest_assertNotificationContent( + notification, + messageContent: try XCTUnwrap(genericMessage.content), + isGroup: isGroup, + isTeam: isTeam + ) + + } + } + + func testGenerateNewMessageNotifications_Is_Group_Conversation_And_Is_Personal_User() async throws { + + // Mock + + let isGroup = true + let isTeam = false + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let messagesCapable = getAllMessagesCapable() + + for messageCapable in messagesCapable { + let genericMessage = GenericMessage(content: messageCapable) + sut = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let notification = await sut.buildContent() + + try await internalTest_assertNotificationContent( + notification, + messageContent: try XCTUnwrap(genericMessage.content), + isGroup: isGroup, + isTeam: isTeam + ) + + } + } + + func testGenerateNewMessageNotifications_Is_OneOnOne_Conversation_And_Team() async throws { + + // Mock + + let isGroup = false + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let messagesCapable = getAllMessagesCapable() + + for messageCapable in messagesCapable { + let genericMessage = GenericMessage(content: messageCapable) + sut = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let notificationContent = await sut.buildContent() + + try await internalTest_assertNotificationContent( + notificationContent, + messageContent: try XCTUnwrap(genericMessage.content), + isGroup: isGroup, + isTeam: isTeam + ) + + } + } + + private func internalTest_assertNotificationContent( + _ notificationContent: UNMutableNotificationContent, + messageContent: GenericMessage.OneOf_Content, + isGroup: Bool, + isTeam: Bool + ) async throws { + + // Title + switch messageContent { + case .ephemeral, .hidden: + XCTAssert(notificationContent.title.isEmpty) + default: + if isGroup { + XCTAssertEqual( + notificationContent.title, + isTeam ? "\(Scaffolding.conversationName) in \(Scaffolding.teamName)" : + "\(Scaffolding.conversationName)" + ) + } else { + XCTAssertEqual( + notificationContent.title, + isTeam ? "\(Scaffolding.senderName) in \(Scaffolding.teamName)" : "\(Scaffolding.senderName)" + ) + } + } + + // Body + switch messageContent { + case .image: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared a picture" : "Shared a picture" + ) + case let .asset(asset): + switch asset.original.metaData { + case .image: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared a picture" : "Shared a picture" + ) + case .video: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared a video" : "Shared a video" + ) + case .audio: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared an audio message" : "Shared an audio message" + ) + default: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared a file" : "Shared a file" + ) + } + case .knock: + XCTAssertEqual(notificationContent.body, isGroup ? "\(Scaffolding.senderName) pinged you" : "Pinged you") + case .text, .composite: + XCTAssertEqual(notificationContent.body, "\(Scaffolding.senderName): Hello") + case .hidden: + XCTAssertEqual(notificationContent.body, "New message") + case .location: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) shared a location" : "Shared a location" + ) + case .ephemeral: + XCTAssertEqual(notificationContent.body, "Someone sent a message") + default: + XCTFail("Not handled") + } + + XCTAssert(!notificationContent.body.isEmpty) + + // Category + XCTAssertEqual( + notificationContent.categoryIdentifier, + NotificationCategory.unmutedConversation.rawValue + ) + + // Sound + switch messageContent { + case .knock: + XCTAssertEqual(notificationContent.sound, UNNotificationSound(named: .init("ping_from_them.caf"))) + default: + XCTAssertEqual(notificationContent.sound, UNNotificationSound(named: .init("default"))) + } + + // Thread ID + switch messageContent { + case .ephemeral: + XCTAssertEqual(notificationContent.threadIdentifier, "") + default: + XCTAssertEqual( + notificationContent.threadIdentifier, + Scaffolding.conversationID.uuid.uuidString.lowercased() + ) + } + + // User info + XCTAssertEqual(notificationContent.userInfo["selfUserIDString"] as! UUID, .mockID1) + XCTAssertEqual(notificationContent.userInfo["senderIDString"] as! UUID, .mockID3) + XCTAssertEqual(notificationContent.userInfo["conversationIDString"] as! UUID, .mockID2) + + } + + private func getAllMessagesCapable() -> [MessageCapable] { + var composite = Composite() + var textItem = Composite.Item() + textItem.text = Text(content: "Hello") + composite.items = [textItem] + + var audioAsset = Asset() + audioAsset.original.metaData = .audio(Asset.AudioMetaData()) + + var videoAsset = Asset() + videoAsset.original.metaData = .video(Asset.VideoMetaData()) + + var imageAsset = Asset() + imageAsset.original.metaData = .image(Asset.ImageMetaData()) + + return [ + Location(), + Knock(), + ImageAsset(), + Ephemeral(), + Text(content: "Hello"), + composite, + Asset(), + audioAsset, + videoAsset, + imageAsset, + MessageHide() + ] + } + + private func setupMock(isGroup: Bool, isTeam: Bool) async { + let conversation = await context.perform { [self] in + modelHelper.createGroupConversation(in: context) + } + conversationLocalStore.fetchOrCreateConversationIdDomain_MockValue = conversation + conversationLocalStore.conversationMutedMessageTypesIncludingAvailability_MockValue = .some(.none) + conversationLocalStore.lastReadServerTimestamp_MockValue = .now + userLocalStore.fetchOrCreateUserIdDomain_MockValue = await context.perform { [self] in + modelHelper.createUser(in: context) + } + userLocalStore.nameFor_MockValue = Scaffolding.senderName + conversationLocalStore.nameFor_MockValue = Scaffolding.conversationName + conversationLocalStore.isGroupConversation_MockValue = isGroup + userLocalStore.fetchSelfUser_MockValue = await context.perform { [self] in + modelHelper.createSelfUser(in: context) + } + conversationLocalStore.isMessageSilencedSenderIDConversation_MockValue = false + userLocalStore.idFor_MockValue = .mockID1 + userLocalStore.teamNameFor_MockValue = .some(isTeam ? Scaffolding.teamName : nil) + conversationLocalStore.shouldHideNotification_MockValue = false + messageLocalStore.fetchMessageIdConversationIDConversationDomain_MockValue = await context.perform { [self] in + ZMOTRMessage.fetch(withNonce: .mockID1, for: conversation, in: context) + } + messageLocalStore.isMessageMentioningSelfText_MockValue = false + messageLocalStore.isMessageQuotingSelfQuotedMessage_MockValue = false + } + + private enum Scaffolding { + static let senderName = "User1" + static let conversationName = "Conversation1" + static let teamName = "Team1" + static let conversationID = WireAPI.QualifiedID(uuid: .mockID2, domain: "domain.com") + static let userID = UserID(uuid: .mockID3, domain: "domain.com") + } +} diff --git a/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift index 9e6260bc077..44f953a8619 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift @@ -17,6 +17,8 @@ // import WireAPISupport +import WireDataModel +import WireDataModelSupport import XCTest @testable import WireAPI @testable import WireDomain @@ -24,9 +26,44 @@ import XCTest final class NotificationSessionTests: XCTestCase { private var sut: NotificationSession! + private var conversationLocalStore: MockConversationLocalStoreProtocol! + private var userRepository: MockUserRepositoryProtocol! + + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + conversationLocalStore = MockConversationLocalStoreProtocol() + userRepository = MockUserRepositoryProtocol() + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + registerDependencies() + } override func tearDown() async throws { + stack = nil sut = nil + conversationLocalStore = nil + userRepository = nil + try coreDataStackHelper.cleanupDirectory() + modelHelper = nil + coreDataStackHelper = nil + } + + private func registerDependencies() { + Injector.register(ConversationLocalStoreProtocol.self) { + self.conversationLocalStore + } + + Injector.register(UserRepositoryProtocol.self) { + self.userRepository + } } func testNotificationSession_It_Triggers_Callback_When_Pulling_Pending_Events() async throws { @@ -61,6 +98,12 @@ final class NotificationSessionTests: XCTestCase { updateEventsLocalStore.indexOfLastEventEnvelope_MockValue = 1 updateEventsLocalStore.persistEventEnvelopeIndex_MockMethod = { _, _ in } updateEventsLocalStore.storeLastEventIDId_MockMethod = { _ in } + userRepository.isSelfUserIdDomain_MockValue = false + conversationLocalStore.fetchOrCreateConversationIdDomain_MockValue = await context.perform { [self] in + modelHelper.createGroupConversation(in: context) + } + conversationLocalStore.conversationMutedMessageTypesIncludingAvailability_MockValue = .some(.none) + conversationLocalStore.lastReadServerTimestamp_MockValue = .now let updateEventsRepository = UpdateEventsRepository( userID: .mockID1, @@ -102,14 +145,15 @@ final class NotificationSessionTests: XCTestCase { conversationID: ConversationID(uuid: .mockID1, domain: ""), senderID: UserID(uuid: .mockID2, domain: ""), subconversation: "subconversation", - message: "message" + message: "message", + timestamp: .now ) static let proteusMessageAddEvent = ConversationProteusMessageAddEvent( conversationID: ConversationID(uuid: .mockID1, domain: ""), senderID: UserID(uuid: .mockID2, domain: ""), timestamp: .now, - message: .ciphertext("foo"), - externalData: .ciphertext("bar"), + message: .init(encryptedMessage: "foo"), + externalData: .init(encryptedMessage: "bar"), messageSenderClientID: "abc123", messageRecipientClientID: "def456" ) diff --git a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Mute.swift b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Mute.swift index 42df47daace..49bf57fb340 100644 --- a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Mute.swift +++ b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Mute.swift @@ -112,7 +112,7 @@ public extension ZMConversation { } -extension ZMUser { +public extension ZMUser { var mutedMessagesTypes: MutedMessageTypes { switch availability {