From 55ce891d956687513fc389b81ac2f9fd09e0b4b5 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:52:04 +0100 Subject: [PATCH 1/3] add Call notification builders, generate CallKit notif and regular call PN, add UTs --- .../Dependency Injection/Injector.swift | 2 +- .../Builders/Call/CallContent.swift | 60 +++ .../Call/CallKitNotificationBuilder.swift | 238 +++++++++++ .../Call/CallNotificationBuilder.swift | 263 +++++++++++++ .../ConversationNotificationBuilder.swift | 155 ++++++-- .../NewMessageNotificationBuilder.swift | 7 +- .../CallNotificationBodyComposer.swift | 36 ++ .../Notifications/NotificationBody.swift | 20 +- .../Notifications/NotificationService.swift | 1 + .../Notifications/NotificationSession.swift | 7 +- .../ConversationLocalStore.swift | 12 + .../generated/AutoMockable.generated.swift | 18 + .../CallKitNotificationBuilderTests.swift | 350 +++++++++++++++++ .../CallNotificationBuilderTests.swift | 370 ++++++++++++++++++ .../NewMessageNotificationBuilderTests.swift | 1 + .../NotificationSessionTests.swift | 1 + 16 files changed, 1504 insertions(+), 37 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift create mode 100644 WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift create mode 100644 WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift create mode 100644 WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift create mode 100644 WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift create mode 100644 WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift diff --git a/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift b/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift index df14c9277e6..9c21c088522 100644 --- a/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift +++ b/WireDomain/Sources/WireDomain/Dependency Injection/Injector.swift @@ -100,7 +100,7 @@ enum Injector { // MARK: - Resolve static func resolve() -> Service { - typealias FactoryType = () -> Any + typealias FactoryType = (()) -> Any return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in factory(()) } diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift new file mode 100644 index 00000000000..8eb4365d2b5 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift @@ -0,0 +1,60 @@ +// +// 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 CallContent: Decodable { + let type: String + let properties: Properties? + let callerUserID: String? + let callerClientID: String + let resp: Bool + + enum CodingKeys: String, CodingKey { + case type + case properties = "props" + case callerUserID = "src_userid" + case callerClientID = "src_clientid" + case resp + } + + struct Properties: Decodable { + private let videosend: String + + var isVideo: Bool { + videosend == "true" + } + } +} + +extension CallContent { + static func decode(from calling: Calling) -> Self? { + let decoder = JSONDecoder() + + guard let data = calling.content.data(using: .utf8) else { + return nil + } + + do { + let callContent = try decoder.decode(Self.self, from: data) + return callContent + } catch { + return nil + } + } +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift new file mode 100644 index 00000000000..fc540c4d6b8 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift @@ -0,0 +1,238 @@ +// +// 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 +import CallKit +import WireLogging + +protocol CallKitReporting { + static func reportIncomingCall(payload: [AnyHashable: Any]) async throws +} + +extension CXProvider: CallKitReporting { + static func reportIncomingCall(payload: [AnyHashable : Any]) async throws { + try await CXProvider.reportNewIncomingVoIPPushPayload(payload) + } +} + +/// Handles a `CallKit` notification related to an incoming / ending call +struct CallKitNotificationBuilder: NotificationBuilder { + + private enum CallKitState: Equatable { + case initiatesRinging + case terminatesRinging + case unhandled + + init(callContent: CallContent, wasCallHandleReported: Bool) { + let isStartCall = callContent.type.isOne(of: ["SETUP", "GROUPSTART", "CONFSTART"]) + let isIncomingCall = isStartCall && !callContent.resp + let isEndCall = callContent.type.isOne(of: ["CANCEL", "GROUPEND", "CONFEND"]) + let isAnsweredElsewhere = isStartCall && callContent.resp + let isRejected = callContent.type == "REJECT" + + if isIncomingCall && !wasCallHandleReported { + self = .initiatesRinging + } else if isEndCall || isAnsweredElsewhere || isRejected { + self = .terminatesRinging + } else { + self = .unhandled + } + } + } + + private struct Context { + let accountID: String + let conversationID: String + let isGroupConversation: Bool + let callerName: String? + let conversationName: String? + let teamName: String? + let shouldRing: Bool + let isVideo: Bool + } + + private struct Validator { + let isConversationMuted: Bool + let conversationNeedsBackendUpdate: Bool + let isConversationForcedReadOnly: Bool + let isAVSReady: Bool + let isCallKitReady: Bool + let isUserSessionLoaded: Bool + let wasCallHandleReported: Bool + let isCallerSelf: Bool + let isCallStateValid: Bool + + func validate() -> Bool { + !conversationNeedsBackendUpdate + && !isConversationMuted + && !isConversationForcedReadOnly + && isAVSReady + && isCallKitReady + && isUserSessionLoaded + && isCallStateValid + } + } + + private let context: Context + private let validator: Validator + private let callKitReporter: CallKitReporting.Type + + init?( + calling: Calling, + conversationID: ConversationID, + senderID: UserID, + accountID: UUID, + userDefaults: UserDefaults = .standard, + callKitReporting: CallKitReporting.Type = CXProvider.self + ) async { + guard let callContent: CallContent = .decode(from: calling) else { + return nil + } + + let handle = "\(accountID.transportString())+\(conversationID.uuid.transportString())" + let knownCallHandles = userDefaults.object(forKey: "knownCalls") as? [String] ?? [] + let wasCallHandleReported = knownCallHandles.contains(handle) + + let callKitState = CallKitState( + callContent: callContent, + wasCallHandleReported: wasCallHandleReported + ) + + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() + let userLocalStore: UserLocalStoreProtocol = Injector.resolve() + + let conversation = await conversationLocalStore.fetchOrCreateConversation( + id: conversationID.uuid, + domain: conversationID.domain + ) + let selfUser = await userLocalStore.fetchSelfUser() + let caller = await userLocalStore.fetchOrCreateUser( + id: senderID.uuid, + domain: senderID.domain + ) + + // Validation criteria + + let needsToBeUpdatedFromBackend = await conversationLocalStore.conversationNeedsBackendUpdate(conversation) + let mutedMessagesTypes = await conversationLocalStore.conversationMutedMessageTypesIncludingAvailability(conversation) + let isConversationMuted = mutedMessagesTypes == .all + let isConversationForcedReadOnly = await conversationLocalStore.isConversationForcedReadOnly(conversation) + let isAVSReady = userDefaults.bool(forKey: "isAVSReady") + let isCallKitReady = userDefaults.bool(forKey: "isCallKitAvailable") + let loadedUserSessions = userDefaults.object(forKey: "loadedUserSessions") as? [String] ?? [] + let loaderUserSessionsIDs = loadedUserSessions.compactMap(UUID.init(uuidString:)) + let isUserSessionLoaded = loaderUserSessionsIDs.contains(accountID) + + self.validator = Validator( + isConversationMuted: isConversationMuted, + conversationNeedsBackendUpdate: needsToBeUpdatedFromBackend, + isConversationForcedReadOnly: isConversationForcedReadOnly, + isAVSReady: isAVSReady, + isCallKitReady: isCallKitReady, + isUserSessionLoaded: isUserSessionLoaded, + wasCallHandleReported: wasCallHandleReported, + isCallerSelf: selfUser == caller, + isCallStateValid: callKitState != .unhandled + ) + + // Context + + let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation) + let conversationName = await conversationLocalStore.name(for: conversation) + let teamName = await userLocalStore.teamName(for: selfUser) + let callerName = await userLocalStore.name(for: caller) + + self.context = Context( + accountID: accountID.uuidString, + conversationID: conversationID.uuid.uuidString, + isGroupConversation: isGroupConversation, + callerName: callerName, + conversationName: conversationName, + teamName: teamName, + shouldRing: callKitState == .initiatesRinging, + isVideo: callContent.properties?.isVideo ?? false + ) + + self.callKitReporter = callKitReporting + } + + + func shouldBuildNotification() async -> Bool { + validator.validate() + } + + func buildContent() async -> UNMutableNotificationContent { + let payload: [String: Any] = [ + "accountID": context.accountID, + "conversationID": context.conversationID, + "shouldRing": context.shouldRing, + "callerName": makeTitle() ?? "", + "hasVideo": context.isVideo + ] + + do { + WireLogger.calling.info("waking up main app to handle call event") + try await callKitReporter.reportIncomingCall(payload: payload) + } catch let error { + WireLogger.calling.error( + "failed to wake up main app: \(error.localizedDescription)" + ) + } + + // `CallKit` notification is automatically generated by the system. + // so we simply return an empty notification to comply with the protocol requirement. + return UNMutableNotificationContent() + } + + // MARK: - Helpers + + private func makeTitle() -> String? { + let isGroupConversation = context.isGroupConversation + let teamName = context.teamName + let conversationName = context.conversationName + let callerName = context.callerName + + guard let conversationName, let callerName 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: callerName, team: teamName) + } else { + .sender(sender: callerName) + } + } + + return NotificationTitle + .newMessage(format) + .make() + } + +} + + + + diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift new file mode 100644 index 00000000000..7f8dcf66758 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift @@ -0,0 +1,263 @@ +// +// 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 + +/// Handles a regular push notification related to an incoming / missed call +struct CallNotificationBuilder: NotificationBuilder { + + private enum CallState: Equatable { + case incomingCall(video: Bool) + case missedCall + case unhandled + + init(callContent: CallContent) { + let isStartCall = callContent.type.isOne(of: ["SETUP", "GROUPSTART", "CONFSTART"]) + let isIncomingCall = isStartCall && !callContent.resp + let isEndCall = callContent.type.isOne(of: ["CANCEL", "GROUPEND", "CONFEND"]) + + if isIncomingCall { + self = .incomingCall(video: callContent.properties?.isVideo ?? false) + } else if isEndCall { + self = .missedCall + } else { + self = .unhandled + } + + } + } + + private struct Context { + let callState: CallState + let callerID: UUID? + let callerName: String? + let conversationName: String? + let isGroupConversation: Bool + let teamName: String? + let conversationID: WireAPI.QualifiedID + let selfUserID: UUID + } + + private struct Validator { + let isCallStateValid: Bool + let isCallerSelf: Bool + let isConversationMuted: Bool + let isCallTimedOut: Bool + + func validate() -> Bool { + isCallStateValid + && !isCallerSelf + && !isConversationMuted + && !isCallTimedOut + } + } + + private let context: Context + private let validator: Validator + + init?( + calling: Calling, + at time: Date?, + conversationID: ConversationID, + senderID: UserID + ) async { + guard let callContent: CallContent = .decode(from: calling) else { + return nil + } + + let callState = CallState(callContent: callContent) + + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() + let userLocalStore: UserLocalStoreProtocol = Injector.resolve() + + let conversation = await conversationLocalStore.fetchOrCreateConversation( + id: conversationID.uuid, + domain: conversationID.domain + ) + + let selfUser = await userLocalStore.fetchSelfUser() + + let caller = await userLocalStore.fetchOrCreateUser( + id: senderID.uuid, + domain: senderID.domain + ) + + // Validation criteria + + let mutedMessagesTypes = await conversationLocalStore.conversationMutedMessageTypesIncludingAvailability(conversation) + let isConversationMuted = mutedMessagesTypes == .all + let isCallTimeOut = time != nil ? Int(Date.now.timeIntervalSince(time!)) > 30 : true + + self.validator = Validator( + isCallStateValid: callState != .unhandled, + isCallerSelf: selfUser == caller, + isConversationMuted: isConversationMuted, + isCallTimedOut: isCallTimeOut + ) + + // Context + + let conversationName = await conversationLocalStore.name(for: conversation) + let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation) + let selfUserID = await userLocalStore.id(for: selfUser) + let teamName = await userLocalStore.teamName(for: selfUser) + let callerName = await userLocalStore.name(for: caller) + let callerID = callContent.callerUserID.flatMap(UUID.init(transportString:)) + + self.context = Context( + callState: callState, + callerID: callerID, + callerName: callerName, + conversationName: conversationName, + isGroupConversation: isGroupConversation, + teamName: teamName, + conversationID: conversationID, + selfUserID: selfUserID + ) + } + + func shouldBuildNotification() async -> Bool { + validator.validate() + } + + func buildContent() async -> UNMutableNotificationContent { + switch context.callState { + case .incomingCall(let isVideo): + buildIncomingCallNotification(isVideo: isVideo) + case .missedCall: + buildMissedCallNotification() + case .unhandled: + fatalError() + } + } + + // MARK: - Build notifications + + private func buildIncomingCallNotification(isVideo: Bool) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let isGroupConversation = context.isGroupConversation + let senderName = context.callerName + + if let title = makeTitle() { + content.title = title + } + + let body = NotificationBody.call( + isVideo ? + .isCallingWithVideo(senderName: isGroupConversation ? senderName : nil) : + .isCalling(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 buildMissedCallNotification() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let isGroupConversation = context.isGroupConversation + let senderName = context.callerName + + if let title = makeTitle() { + content.title = title + } + + let body = NotificationBody.call( + .called(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 + } + + // MARK: - Helpers + + private func makeTitle() -> String? { + let isGroupConversation = context.isGroupConversation + let teamName = context.teamName + let conversationName = context.conversationName + let callerName = context.callerName + + guard let conversationName, let callerName 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: callerName, team: teamName) + } else { + .sender(sender: callerName) + } + } + + return NotificationTitle + .newMessage(format) + .make() + } + + private func makeSound() -> UNNotificationSound { + let notificationSound = switch context.callState { + case .incomingCall: + NotificationSound.call + case .missedCall: + NotificationSound.default + case .unhandled: + fatalError() + } + + let notificationSoundName = UNNotificationSoundName(notificationSound.rawValue) + return UNNotificationSound(named: notificationSoundName) + } + + private func makeCategory() -> String { + switch context.callState { + case .incomingCall: + NotificationCategory.incomingCall.rawValue + case .missedCall: + NotificationCategory.missedCall.rawValue + case .unhandled: + fatalError() + } + } + + private func makeUserInfo() -> [AnyHashable: Any] { + var userInfo: [AnyHashable: Any] = [:] + + userInfo["selfUserIDString"] = context.selfUserID + userInfo["senderIDString"] = context.callerID + userInfo["conversationIDString"] = context.conversationID.uuid + + return userInfo + } +} diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift index e367eda9e12..563ede83cc8 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift @@ -16,15 +16,16 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import UserNotifications import WireAPI import WireDataModel +import WireLogging struct ConversationNotificationBuilder: NotificationBuilder { - + private struct Context { let senderID: UserID let conversationID: ConversationID + let accountID: UUID let isSelfUser: Bool let isConversationMuted: Bool let eventTimeStamp: Date? @@ -35,7 +36,8 @@ struct ConversationNotificationBuilder: NotificationBuilder { private let context: Context init( - event: ConversationEvent + event: ConversationEvent, + accountID: UUID ) async { self.event = event @@ -64,6 +66,7 @@ struct ConversationNotificationBuilder: NotificationBuilder { self.context = Context( senderID: event.senderID, conversationID: event.conversationID, + accountID: accountID, isSelfUser: isSelfUser == true, isConversationMuted: isConversationMuted, eventTimeStamp: eventTimeStamp, @@ -77,38 +80,58 @@ struct ConversationNotificationBuilder: 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() + + guard let genericMessage = getGenericMessage( + decryptedMessage: decryptedMessage + ) else { return UNMutableNotificationContent() } + + if genericMessage.hasCalling { + + guard let callBuilder = await makeCallBuilder( + calling: genericMessage.calling, + at: context.eventTimeStamp + ) else { return UNMutableNotificationContent() } + + builder = callBuilder + + } else { + + builder = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: mlsMessageEvent.conversationID, + senderID: mlsMessageEvent.senderID + ) + } - 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() + + guard let genericMessage = getGenericMessage( + decryptedMessage: decryptedMessage, + externalMessage: externalEncryptedMessage + ) else { return UNMutableNotificationContent() } + + if genericMessage.hasCalling { + + guard let callBuilder = await makeCallBuilder( + calling: genericMessage.calling, + at: context.eventTimeStamp + ) else { return UNMutableNotificationContent() } + + builder = callBuilder + + } else { + + builder = await NewMessageNotificationBuilder( + message: genericMessage, + conversationID: proteusMessageEvent.conversationID, + senderID: proteusMessageEvent.senderID + ) + } - builder = await NewMessageNotificationBuilder( - message: genericMessage, - conversationID: proteusMessageEvent.conversationID, - senderID: proteusMessageEvent.senderID - ) - default: // TODO: [WPB-11175] - Generate notifications for other events return UNMutableNotificationContent() } @@ -131,11 +154,79 @@ struct ConversationNotificationBuilder: NotificationBuilder { let eventTimeStamp, let lastReadTimestamp, lastReadTimestamp.compare(eventTimeStamp) != .orderedAscending - else { - return false - } + else { return false } return true } - + + // MARK: - Helpers + + private func makeCallBuilder( + calling: Calling, + at date: Date? + ) async -> NotificationBuilder? { + let callKitBuilder = await makeCallKitNotificationBuilder( + calling: calling, + at: date + ) + + // Checking early on that the builder should actually build the `CallKit` notification + // if not we fallback to the regular call notification builder. + if let callKitBuilder, await callKitBuilder.shouldBuildNotification() { + return callKitBuilder + } else if let callNotifBuilder = await makeCallRegularNotificationBuilder( + calling: calling, + at: date + ) { + return callNotifBuilder + } else { + return nil + } + } + + private func makeCallKitNotificationBuilder( + calling: Calling, + at date: Date? + ) async -> NotificationBuilder? { + guard let callKitNotifBuilder = await CallKitNotificationBuilder( + calling: calling, + conversationID: context.conversationID, + senderID: context.senderID, + accountID: context.accountID + ) else { + return nil + } + + return callKitNotifBuilder + } + + private func makeCallRegularNotificationBuilder( + calling: Calling, + at date: Date? + ) async -> NotificationBuilder? { + guard let callNotifBuilder = await CallNotificationBuilder( + calling: calling, + at: date, + conversationID: context.conversationID, + senderID: context.senderID + ) else { + return nil + } + + return callNotifBuilder + } + + private func getGenericMessage( + decryptedMessage: String?, + externalMessage: String? = nil + ) -> GenericMessage? { + guard let decryptedMessage, + let (genericMessage, _) = ProtobufMessageHelper.getProtobufMessage( + from: decryptedMessage, + externalData: externalMessage + ) else { return nil } + + return genericMessage + } + } diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift index 86fd97ddbc9..90b7ac3a2ab 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/NewMessageNotificationBuilder.swift @@ -38,6 +38,7 @@ struct NewMessageNotificationBuilder: NotificationBuilder { let senderID: UUID let selfUserID: UUID let hidesNotificationContent: Bool + let isConversationReadOnly: Bool } private let message: GenericMessage @@ -75,6 +76,7 @@ struct NewMessageNotificationBuilder: NotificationBuilder { ) let selfUserID = await userLocalStore.id(for: selfUser) let shouldHideNotification = await conversationLocalStore.shouldHideNotification() + let isConversationReadOnly = await conversationLocalStore.isConversationForcedReadOnly(conversation) self.context = Context( senderName: senderName, @@ -85,12 +87,13 @@ struct NewMessageNotificationBuilder: NotificationBuilder { conversationID: conversationID, senderID: senderID.uuid, selfUserID: selfUserID, - hidesNotificationContent: shouldHideNotification + hidesNotificationContent: shouldHideNotification, + isConversationReadOnly: isConversationReadOnly ) } func shouldBuildNotification() async -> Bool { - !context.isMessageSilenced + !context.isMessageSilenced && !context.isConversationReadOnly } func buildContent() async -> UNMutableNotificationContent { diff --git a/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift new file mode 100644 index 00000000000..731618145c5 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift @@ -0,0 +1,36 @@ +// +// 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 CallNotificationBodyComposer { + let format: NotificationBody.CallBodyFormat + + // TODO: [WPB-15153] - Localize strings + func make() -> String { + switch format { + case .isCalling(let senderName): + senderName != nil ? "\(senderName!) is calling" : "Incoming call" + case .isCallingWithVideo(let senderName): + senderName != nil ? "\(senderName!) is calling with video" : "Incoming video call" + case .called(let senderName): + senderName != nil ? "\(senderName!) called" : "Missed call" + } + } +} + diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift index 6b87b419216..8e434e63edc 100644 --- a/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift @@ -21,6 +21,7 @@ import Foundation enum NotificationBody { case newMessage(MessageBodyFormat) + case call(CallBodyFormat) case bundled(messagesCount: Int) func make() -> String { @@ -31,7 +32,14 @@ enum NotificationBody { ) return newMessageBodyComposer.make() - + + case let .call(callBodyFormat): + let callBodyComposer = CallNotificationBodyComposer( + format: callBodyFormat + ) + + return callBodyComposer.make() + case let .bundled(count): return "\(count) new messages." } @@ -70,5 +78,15 @@ extension NotificationBody { /// `New message` case hidden } + + /// The expected formats for the body of a call notification. + enum CallBodyFormat { + /// `[sender name] is calling` or `Incoming call` if sender is nil. + case isCalling(senderName: String?) + /// `[sender name] is calling with video` or `Incoming video call` if sender is nil. + case isCallingWithVideo(senderName: String?) + /// `[sender name] called` or `Missed called` if sender is nil. + case called(senderName: String?) + } } diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift index 7bc4d02d28d..ada61a2189c 100644 --- a/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationService.swift @@ -113,6 +113,7 @@ final class NotificationService: UNNotificationServiceExtension { ) return NotificationSession( + accountID: userID, updateEventsRepository: updateEventsRepository ) { [weak self] notificationContent in self?.finishWithNotification(content: notificationContent) diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift index 77981fe8ac4..18c98ff984d 100644 --- a/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationSession.swift @@ -31,15 +31,18 @@ final class NotificationSession { // MARK: - Properties + private let accountID: UUID private let updateEventsRepository: any UpdateEventsRepositoryProtocol private var subscription: AnyCancellable? // MARK: - Object lifecycle init( + accountID: UUID, updateEventsRepository: any UpdateEventsRepositoryProtocol, onNotificationContent: @escaping (UNMutableNotificationContent) -> Void ) { + self.accountID = accountID self.updateEventsRepository = updateEventsRepository self.subscription = updateEventsRepository.observePendingEvents() .collect() // Collects all the events batches. @@ -93,7 +96,8 @@ final class NotificationSession { switch event { case let .conversation(conversationEvent): notificationBuilder = await ConversationNotificationBuilder( - event: conversationEvent + event: conversationEvent, + accountID: accountID ) // TODO: [WPB-10218] - Generate notif for other update events case let .featureConfig(featureConfigEvent): @@ -113,6 +117,7 @@ final class NotificationSession { } let notificationContent = await notificationBuilder.buildContent() + notificationContent.interruptionLevel = .timeSensitive notifications.append(notificationContent) } diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift index 217705b4d27..fa7c152f248 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift @@ -419,6 +419,10 @@ public protocol ConversationLocalStoreProtocol { ) async -> Bool func shouldHideNotification() async -> Bool + + func conversationNeedsBackendUpdate( + _ conversation: ZMConversation + ) async -> Bool } public final class ConversationLocalStore: ConversationLocalStoreProtocol { @@ -882,6 +886,14 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { ) } } + + public func conversationNeedsBackendUpdate( + _ conversation: ZMConversation + ) async -> Bool { + await context.perform { + conversation.needsToBeUpdatedFromBackend + } + } public func isConversationArchived( _ conversation: ZMConversation diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index 089e4f5c3a2..db478045af1 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -949,6 +949,24 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol } } + // MARK: - conversationNeedsBackendUpdate + + public var conversationNeedsBackendUpdate_Invocations: [ZMConversation] = [] + public var conversationNeedsBackendUpdate_MockMethod: ((ZMConversation) async -> Bool)? + public var conversationNeedsBackendUpdate_MockValue: Bool? + + public func conversationNeedsBackendUpdate(_ conversation: ZMConversation) async -> Bool { + conversationNeedsBackendUpdate_Invocations.append(conversation) + + if let mock = conversationNeedsBackendUpdate_MockMethod { + return await mock(conversation) + } else if let mock = conversationNeedsBackendUpdate_MockValue { + return mock + } else { + fatalError("no mock for `conversationNeedsBackendUpdate`") + } + } + } public class MockConversationProtobufMessageProcessorProtocol: ConversationProtobufMessageProcessorProtocol { diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift new file mode 100644 index 00000000000..04e1f271ec0 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift @@ -0,0 +1,350 @@ +// +// 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 CallKitNotificationBuilderTests: XCTestCase { + private var sut: CallKitNotificationBuilder! + private var conversationLocalStore: MockConversationLocalStoreProtocol! + private var userLocalStore: MockUserLocalStoreProtocol! + + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + private var defaults: UserDefaults! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + defaults = UserDefaults(suiteName: UUID.mockID1.uuidString)! + conversationLocalStore = MockConversationLocalStoreProtocol() + userLocalStore = MockUserLocalStoreProtocol() + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + registerDependencies() + } + + override func tearDown() async throws { + defaults.removePersistentDomain(forName: UUID.mockID1.uuidString) + defaults = nil + stack = nil + sut = nil + conversationLocalStore = 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 + } + } + + func testGenerateCallKitNotification_Is_Group_Conversation_And_Is_Team_User() async throws { + + // Mock + + let isGroup = true + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callKitTestUsecases = getCallKitTestUseCases() + + for callKitTestUsecase in callKitTestUsecases { + var calling = Calling() + calling.content = callKitTestUsecase.json + + sut = await CallKitNotificationBuilder( + calling: calling, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID, + accountID: Scaffolding.accountID, + userDefaults: defaults, + callKitReporting: MockCallKitReporting.self + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + _ = await sut.buildContent() + + try await internalTest_assertNotificationContent( + callKitTestUsecase: callKitTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + } + + func testGenerateCallKitNotification_Is_Group_Conversation_And_Is_Personal_User() async throws { + + // Mock + + let isGroup = true + let isTeam = false + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callKitTestUsecases = getCallKitTestUseCases() + + for callKitTestUsecase in callKitTestUsecases { + var calling = Calling() + calling.content = callKitTestUsecase.json + + sut = await CallKitNotificationBuilder( + calling: calling, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID, + accountID: Scaffolding.accountID, + userDefaults: defaults, + callKitReporting: MockCallKitReporting.self + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + _ = await sut.buildContent() + + try await internalTest_assertNotificationContent( + callKitTestUsecase: callKitTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + } + + func testGenerateCallKitNotification_Is_OneOnOne_Conversation_And_Team() async throws { + + // Mock + + let isGroup = false + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callKitTestUsecases = getCallKitTestUseCases() + + for callKitTestUsecase in callKitTestUsecases { + var calling = Calling() + calling.content = callKitTestUsecase.json + + sut = await CallKitNotificationBuilder( + calling: calling, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID, + accountID: Scaffolding.accountID, + userDefaults: defaults, + callKitReporting: MockCallKitReporting.self + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + _ = await sut.buildContent() + + try await internalTest_assertNotificationContent( + callKitTestUsecase: callKitTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + } + + func testGenerateCallKitNotification_Should_Build_Notification_Returns_False() async { + // Mock + + let isGroup = false + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + + // An example payload that will be treated as an `unhandled` case. + let unhandledCallJson = """ + { + "type": "REMOTEMUTE", + "src_clientid": "clientid", + "resp": false, + "props": { "videosend": "false" } + } + """ + + var calling = Calling() + calling.content = unhandledCallJson + + sut = await CallKitNotificationBuilder( + calling: calling, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID, + accountID: Scaffolding.accountID, + userDefaults: defaults, + callKitReporting: MockCallKitReporting.self + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, false) + } + + private func internalTest_assertNotificationContent( + callKitTestUsecase: CallKitTestUseCase, + isGroup: Bool, + isTeam: Bool + ) async throws { + let callKitPayload = try XCTUnwrap(MockCallKitReporting.payload) + + XCTAssertEqual(callKitPayload["accountID"] as! String, Scaffolding.accountID.uuidString) + XCTAssertEqual(callKitPayload["conversationID"] as! String, Scaffolding.conversationID.uuid.uuidString) + + switch callKitTestUsecase { + case .incomingAudioCall(let string): + XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, true) + XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, false) + case .incomingVideoCall(let string): + XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, true) + XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, true) + case .endingCall(let string): + XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, false) + XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, false) + } + + let callerName = if isGroup { + isTeam ? "\(Scaffolding.conversationName) in \(Scaffolding.teamName)" : + "\(Scaffolding.conversationName)" + + } else { + isTeam ? "\(Scaffolding.senderName) in \(Scaffolding.teamName)" : "\(Scaffolding.senderName)" + } + + XCTAssertEqual(callKitPayload["callerName"] as! String, callerName) + + } + + // MARK: - Tested use cases + + private enum CallKitTestUseCase { + case incomingAudioCall(String) + case incomingVideoCall(String) + case endingCall(String) + + var json: String { + switch self { + case .incomingAudioCall(let string): + string + case .incomingVideoCall(let string): + string + case .endingCall(let string): + string + } + } + } + + + private func getCallKitTestUseCases() -> [CallKitTestUseCase] { + let startAudioCallJson = setupCallingContentMock(type: "SETUP") + let startVideoCallJson = setupCallingContentMock(type: "SETUP", isVideo: true) + let endCallJson = setupCallingContentMock(type: "CANCEL") + + return [ + .incomingAudioCall(startAudioCallJson), + .incomingVideoCall(startVideoCallJson), + .endingCall(endCallJson) + ] + + } + + // MARK: - Mocks + + struct MockCallKitReporting: CallKitReporting { + nonisolated(unsafe) static var payload: [AnyHashable: Any]! + + static func reportIncomingCall(payload: [AnyHashable : Any]) async throws { + Self.payload = payload + } + } + + private func setupCallingContentMock( + type: String, + isVideo: Bool = false + ) -> String { + """ + { + "type": "\(type)", + "src_clientid": "clientid", + "resp": false, + "props": { "videosend": "\(isVideo)" } + } + """ + } + + private func setupMock( + isGroup: Bool, + isTeam: Bool + ) async { + + defaults.set(true, forKey: "isAVSReady") + defaults.set(true, forKey: "isCallKitAvailable") + defaults.set([Scaffolding.accountID.uuidString], forKey: "loadedUserSessions") + + 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) + } + conversationLocalStore.isConversationForcedReadOnly_MockValue = false + conversationLocalStore.conversationNeedsBackendUpdate_MockValue = false + 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 + } + + 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") + static let accountID = UUID.mockID10 + } + +} + diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift new file mode 100644 index 00000000000..395884cfc89 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift @@ -0,0 +1,370 @@ +// +// 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 CallNotificationBuilderTests: XCTestCase { + private var sut: CallNotificationBuilder! + private var conversationLocalStore: MockConversationLocalStoreProtocol! + 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() + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + registerDependencies() + } + + override func tearDown() async throws { + stack = nil + sut = nil + conversationLocalStore = 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 + } + } + + func testGenerateCallNotification_Is_Group_Conversation_And_Is_Team_User() async throws { + + // Mock + + let isGroup = true + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callingTestUsecases = getCallingTestUseCases() + + for callingTestUsecase in callingTestUsecases { + var calling = Calling() + calling.content = callingTestUsecase.json + + sut = await CallNotificationBuilder( + calling: calling, + at: .now, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + let content = await sut.buildContent() + + try await internalTest_assertNotificationContent( + content, + callingTestUsecase: callingTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + } + + func testGenerateCallNotification_Is_Group_Conversation_And_Is_Personal_User() async throws { + + // Mock + + let isGroup = true + let isTeam = false + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callingTestUsecases = getCallingTestUseCases() + + for callingTestUsecase in callingTestUsecases { + var calling = Calling() + calling.content = callingTestUsecase.json + + sut = await CallNotificationBuilder( + calling: calling, + at: .now, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + let content = await sut.buildContent() + + try await internalTest_assertNotificationContent( + content, + callingTestUsecase: callingTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + + } + + func testGenerateCallNotification_Is_OneOnOne_Conversation_And_Team() async throws { + + // Mock + + let isGroup = false + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + let callingTestUsecases = getCallingTestUseCases() + + for callingTestUsecase in callingTestUsecases { + var calling = Calling() + calling.content = callingTestUsecase.json + + sut = await CallNotificationBuilder( + calling: calling, + at: .now, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, true) + + let content = await sut.buildContent() + + try await internalTest_assertNotificationContent( + content, + callingTestUsecase: callingTestUsecase, + isGroup: isGroup, + isTeam: isTeam + ) + } + + } + + func testGenerateCallNotification_Should_Build_Notification_Returns_False() async { + // Mock + + let isGroup = false + let isTeam = true + + await setupMock(isGroup: isGroup, isTeam: isTeam) + + // An example payload that will be treated as an `unhandled` case. + let unhandledCallJson = """ + { + "type": "REJECT", + "src_clientid": "clientid", + "resp": true, + "props": { "videosend": "false" } + } + """ + + + var calling = Calling() + calling.content = unhandledCallJson + + sut = await CallNotificationBuilder( + calling: calling, + at: .now, + conversationID: Scaffolding.conversationID, + senderID: Scaffolding.userID + ) + + let shouldBuildNotification = await sut.shouldBuildNotification() + XCTAssertEqual(shouldBuildNotification, false) + } + + private func internalTest_assertNotificationContent( + _ notificationContent: UNMutableNotificationContent, + callingTestUsecase: CallingTestUseCase, + isGroup: Bool, + isTeam: Bool + ) async throws { + + // Title + 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 callingTestUsecase { + case .incomingAudioCall: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) is calling" : "Incoming call" + ) + case .incomingVideoCall: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) is calling with video" : "Incoming video call" + ) + case .missedCall: + XCTAssertEqual( + notificationContent.body, + isGroup ? "\(Scaffolding.senderName) called" : "Missed call" + ) + } + + // Category + switch callingTestUsecase { + case .incomingAudioCall, .incomingVideoCall: + XCTAssertEqual( + notificationContent.categoryIdentifier, + NotificationCategory.incomingCall.rawValue + ) + case .missedCall: + XCTAssertEqual( + notificationContent.categoryIdentifier, + NotificationCategory.missedCall.rawValue + ) + } + + // Sound + + switch callingTestUsecase { + case .incomingAudioCall, .incomingVideoCall: + XCTAssertEqual( + notificationContent.sound, + UNNotificationSound(named: .init("ringing_from_them_long.caf")) + ) + case .missedCall: + XCTAssertEqual( + notificationContent.sound, + UNNotificationSound(named: .init("default")) + ) + } + + // Thread ID + XCTAssertEqual( + notificationContent.threadIdentifier, + Scaffolding.conversationID.uuid.uuidString.lowercased() + ) + + // User info + XCTAssertEqual(notificationContent.userInfo["selfUserIDString"] as! UUID, .mockID1) + XCTAssertNil(notificationContent.userInfo["senderIDString"]) + XCTAssertEqual(notificationContent.userInfo["conversationIDString"] as! UUID, .mockID2) + + } + + // MARK: - Tested use cases + + private enum CallingTestUseCase { + case incomingAudioCall(String) + case incomingVideoCall(String) + case missedCall(String) + + var json: String { + switch self { + case .incomingAudioCall(let string): + string + case .incomingVideoCall(let string): + string + case .missedCall(let string): + string + } + } + } + + private func getCallingTestUseCases() -> [CallingTestUseCase] { + let startAudioCallJson = setupCallingContentMock(type: "SETUP") + let startVideoCallJson = setupCallingContentMock(type: "SETUP", isVideo: true) + let endCallJson = setupCallingContentMock(type: "CANCEL") + + return [ + .incomingAudioCall(startAudioCallJson), + .incomingVideoCall(startVideoCallJson), + .missedCall(endCallJson) + ] + + } + + // MARK: - Mocks + + private func setupCallingContentMock( + type: String, + isVideo: Bool = false + ) -> String { + """ + { + "type": "\(type)", + "src_clientid": "clientid", + "resp": false, + "props": { "videosend": "\(isVideo)" } + } + """ + } + + 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 + } + + 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/NewMessageNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift index 6c02b42e7ed..37cfe744380 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/NewMessageNotificationBuilderTests.swift @@ -317,6 +317,7 @@ final class NewMessageNotificationBuilderTests: XCTestCase { userLocalStore.fetchSelfUser_MockValue = await context.perform { [self] in modelHelper.createSelfUser(in: context) } + conversationLocalStore.isConversationForcedReadOnly_MockValue = false conversationLocalStore.isMessageSilencedSenderIDConversation_MockValue = false userLocalStore.idFor_MockValue = .mockID1 userLocalStore.teamNameFor_MockValue = .some(isTeam ? Scaffolding.teamName : nil) diff --git a/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift index 44f953a8619..b398ca07272 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/NotificationSessionTests.swift @@ -115,6 +115,7 @@ final class NotificationSessionTests: XCTestCase { ) sut = NotificationSession( + accountID: .mockID5, updateEventsRepository: updateEventsRepository, onNotificationContent: { _ in // Then, all 3 events batches have been received From 8897db800aa661451536dcd368e96bf3654ee5ab Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:57:22 +0100 Subject: [PATCH 2/3] lint and format --- .../Builders/Call/CallContent.swift | 13 +- .../Call/CallKitNotificationBuilder.swift | 75 +++++----- .../Call/CallNotificationBuilder.swift | 69 +++++----- .../ConversationNotificationBuilder.swift | 48 +++---- .../CallNotificationBodyComposer.swift | 6 +- .../Notifications/NotificationBody.swift | 8 +- .../ConversationLocalStore.swift | 4 +- .../CallKitNotificationBuilderTests.swift | 128 +++++++++--------- .../CallNotificationBuilderTests.swift | 112 +++++++-------- 9 files changed, 232 insertions(+), 231 deletions(-) diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift index 8eb4365d2b5..9d6d7ee9152 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallContent.swift @@ -24,7 +24,7 @@ struct CallContent: Decodable { let callerUserID: String? let callerClientID: String let resp: Bool - + enum CodingKeys: String, CodingKey { case type case properties = "props" @@ -32,10 +32,10 @@ struct CallContent: Decodable { case callerClientID = "src_clientid" case resp } - + struct Properties: Decodable { private let videosend: String - + var isVideo: Bool { videosend == "true" } @@ -45,14 +45,13 @@ struct CallContent: Decodable { extension CallContent { static func decode(from calling: Calling) -> Self? { let decoder = JSONDecoder() - + guard let data = calling.content.data(using: .utf8) else { return nil } - + do { - let callContent = try decoder.decode(Self.self, from: data) - return callContent + return try decoder.decode(Self.self, from: data) } catch { return nil } diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift index fc540c4d6b8..d82cf10f787 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift @@ -16,9 +16,9 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import CallKit import WireAPI import WireDataModel -import CallKit import WireLogging protocol CallKitReporting { @@ -26,26 +26,26 @@ protocol CallKitReporting { } extension CXProvider: CallKitReporting { - static func reportIncomingCall(payload: [AnyHashable : Any]) async throws { + static func reportIncomingCall(payload: [AnyHashable: Any]) async throws { try await CXProvider.reportNewIncomingVoIPPushPayload(payload) } } /// Handles a `CallKit` notification related to an incoming / ending call struct CallKitNotificationBuilder: NotificationBuilder { - + private enum CallKitState: Equatable { case initiatesRinging case terminatesRinging case unhandled - + init(callContent: CallContent, wasCallHandleReported: Bool) { let isStartCall = callContent.type.isOne(of: ["SETUP", "GROUPSTART", "CONFSTART"]) let isIncomingCall = isStartCall && !callContent.resp let isEndCall = callContent.type.isOne(of: ["CANCEL", "GROUPEND", "CONFEND"]) let isAnsweredElsewhere = isStartCall && callContent.resp let isRejected = callContent.type == "REJECT" - + if isIncomingCall && !wasCallHandleReported { self = .initiatesRinging } else if isEndCall || isAnsweredElsewhere || isRejected { @@ -55,7 +55,7 @@ struct CallKitNotificationBuilder: NotificationBuilder { } } } - + private struct Context { let accountID: String let conversationID: String @@ -66,7 +66,7 @@ struct CallKitNotificationBuilder: NotificationBuilder { let shouldRing: Bool let isVideo: Bool } - + private struct Validator { let isConversationMuted: Bool let conversationNeedsBackendUpdate: Bool @@ -77,22 +77,22 @@ struct CallKitNotificationBuilder: NotificationBuilder { let wasCallHandleReported: Bool let isCallerSelf: Bool let isCallStateValid: Bool - + func validate() -> Bool { !conversationNeedsBackendUpdate - && !isConversationMuted - && !isConversationForcedReadOnly - && isAVSReady - && isCallKitReady - && isUserSessionLoaded - && isCallStateValid + && !isConversationMuted + && !isConversationForcedReadOnly + && isAVSReady + && isCallKitReady + && isUserSessionLoaded + && isCallStateValid } } - + private let context: Context private let validator: Validator private let callKitReporter: CallKitReporting.Type - + init?( calling: Calling, conversationID: ConversationID, @@ -104,19 +104,19 @@ struct CallKitNotificationBuilder: NotificationBuilder { guard let callContent: CallContent = .decode(from: calling) else { return nil } - + let handle = "\(accountID.transportString())+\(conversationID.uuid.transportString())" let knownCallHandles = userDefaults.object(forKey: "knownCalls") as? [String] ?? [] let wasCallHandleReported = knownCallHandles.contains(handle) - + let callKitState = CallKitState( callContent: callContent, wasCallHandleReported: wasCallHandleReported ) - + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() let userLocalStore: UserLocalStoreProtocol = Injector.resolve() - + let conversation = await conversationLocalStore.fetchOrCreateConversation( id: conversationID.uuid, domain: conversationID.domain @@ -126,11 +126,12 @@ struct CallKitNotificationBuilder: NotificationBuilder { id: senderID.uuid, domain: senderID.domain ) - + // Validation criteria - + let needsToBeUpdatedFromBackend = await conversationLocalStore.conversationNeedsBackendUpdate(conversation) - let mutedMessagesTypes = await conversationLocalStore.conversationMutedMessageTypesIncludingAvailability(conversation) + let mutedMessagesTypes = await conversationLocalStore + .conversationMutedMessageTypesIncludingAvailability(conversation) let isConversationMuted = mutedMessagesTypes == .all let isConversationForcedReadOnly = await conversationLocalStore.isConversationForcedReadOnly(conversation) let isAVSReady = userDefaults.bool(forKey: "isAVSReady") @@ -138,7 +139,7 @@ struct CallKitNotificationBuilder: NotificationBuilder { let loadedUserSessions = userDefaults.object(forKey: "loadedUserSessions") as? [String] ?? [] let loaderUserSessionsIDs = loadedUserSessions.compactMap(UUID.init(uuidString:)) let isUserSessionLoaded = loaderUserSessionsIDs.contains(accountID) - + self.validator = Validator( isConversationMuted: isConversationMuted, conversationNeedsBackendUpdate: needsToBeUpdatedFromBackend, @@ -150,14 +151,14 @@ struct CallKitNotificationBuilder: NotificationBuilder { isCallerSelf: selfUser == caller, isCallStateValid: callKitState != .unhandled ) - + // Context - + let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation) let conversationName = await conversationLocalStore.name(for: conversation) let teamName = await userLocalStore.teamName(for: selfUser) let callerName = await userLocalStore.name(for: caller) - + self.context = Context( accountID: accountID.uuidString, conversationID: conversationID.uuid.uuidString, @@ -168,15 +169,15 @@ struct CallKitNotificationBuilder: NotificationBuilder { shouldRing: callKitState == .initiatesRinging, isVideo: callContent.properties?.isVideo ?? false ) - + self.callKitReporter = callKitReporting } - - + + func shouldBuildNotification() async -> Bool { validator.validate() } - + func buildContent() async -> UNMutableNotificationContent { let payload: [String: Any] = [ "accountID": context.accountID, @@ -185,23 +186,23 @@ struct CallKitNotificationBuilder: NotificationBuilder { "callerName": makeTitle() ?? "", "hasVideo": context.isVideo ] - + do { WireLogger.calling.info("waking up main app to handle call event") try await callKitReporter.reportIncomingCall(payload: payload) - } catch let error { + } catch { WireLogger.calling.error( "failed to wake up main app: \(error.localizedDescription)" ) } - + // `CallKit` notification is automatically generated by the system. // so we simply return an empty notification to comply with the protocol requirement. return UNMutableNotificationContent() } - + // MARK: - Helpers - + private func makeTitle() -> String? { let isGroupConversation = context.isGroupConversation let teamName = context.teamName @@ -230,7 +231,7 @@ struct CallKitNotificationBuilder: NotificationBuilder { .newMessage(format) .make() } - + } diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift index 7f8dcf66758..a61debe2a51 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallNotificationBuilder.swift @@ -21,17 +21,17 @@ import WireDataModel /// Handles a regular push notification related to an incoming / missed call struct CallNotificationBuilder: NotificationBuilder { - + private enum CallState: Equatable { case incomingCall(video: Bool) case missedCall case unhandled - + init(callContent: CallContent) { let isStartCall = callContent.type.isOne(of: ["SETUP", "GROUPSTART", "CONFSTART"]) let isIncomingCall = isStartCall && !callContent.resp let isEndCall = callContent.type.isOne(of: ["CANCEL", "GROUPEND", "CONFEND"]) - + if isIncomingCall { self = .incomingCall(video: callContent.properties?.isVideo ?? false) } else if isEndCall { @@ -39,10 +39,10 @@ struct CallNotificationBuilder: NotificationBuilder { } else { self = .unhandled } - + } } - + private struct Context { let callState: CallState let callerID: UUID? @@ -53,24 +53,24 @@ struct CallNotificationBuilder: NotificationBuilder { let conversationID: WireAPI.QualifiedID let selfUserID: UUID } - + private struct Validator { let isCallStateValid: Bool let isCallerSelf: Bool let isConversationMuted: Bool let isCallTimedOut: Bool - + func validate() -> Bool { isCallStateValid - && !isCallerSelf - && !isConversationMuted - && !isCallTimedOut + && !isCallerSelf + && !isConversationMuted + && !isCallTimedOut } } - + private let context: Context private let validator: Validator - + init?( calling: Calling, at time: Date?, @@ -80,46 +80,47 @@ struct CallNotificationBuilder: NotificationBuilder { guard let callContent: CallContent = .decode(from: calling) else { return nil } - + let callState = CallState(callContent: callContent) - + let conversationLocalStore: ConversationLocalStoreProtocol = Injector.resolve() let userLocalStore: UserLocalStoreProtocol = Injector.resolve() - + let conversation = await conversationLocalStore.fetchOrCreateConversation( id: conversationID.uuid, domain: conversationID.domain ) - + let selfUser = await userLocalStore.fetchSelfUser() - + let caller = await userLocalStore.fetchOrCreateUser( id: senderID.uuid, domain: senderID.domain ) - + // Validation criteria - - let mutedMessagesTypes = await conversationLocalStore.conversationMutedMessageTypesIncludingAvailability(conversation) + + let mutedMessagesTypes = await conversationLocalStore + .conversationMutedMessageTypesIncludingAvailability(conversation) let isConversationMuted = mutedMessagesTypes == .all let isCallTimeOut = time != nil ? Int(Date.now.timeIntervalSince(time!)) > 30 : true - + self.validator = Validator( isCallStateValid: callState != .unhandled, isCallerSelf: selfUser == caller, isConversationMuted: isConversationMuted, isCallTimedOut: isCallTimeOut ) - + // Context - + let conversationName = await conversationLocalStore.name(for: conversation) let isGroupConversation = await conversationLocalStore.isGroupConversation(conversation) let selfUserID = await userLocalStore.id(for: selfUser) let teamName = await userLocalStore.teamName(for: selfUser) let callerName = await userLocalStore.name(for: caller) let callerID = callContent.callerUserID.flatMap(UUID.init(transportString:)) - + self.context = Context( callState: callState, callerID: callerID, @@ -131,14 +132,14 @@ struct CallNotificationBuilder: NotificationBuilder { selfUserID: selfUserID ) } - + func shouldBuildNotification() async -> Bool { validator.validate() } - + func buildContent() async -> UNMutableNotificationContent { switch context.callState { - case .incomingCall(let isVideo): + case let .incomingCall(isVideo): buildIncomingCallNotification(isVideo: isVideo) case .missedCall: buildMissedCallNotification() @@ -146,9 +147,9 @@ struct CallNotificationBuilder: NotificationBuilder { fatalError() } } - + // MARK: - Build notifications - + private func buildIncomingCallNotification(isVideo: Bool) -> UNMutableNotificationContent { let content = UNMutableNotificationContent() let isGroupConversation = context.isGroupConversation @@ -172,7 +173,7 @@ struct CallNotificationBuilder: NotificationBuilder { return content } - + private func buildMissedCallNotification() -> UNMutableNotificationContent { let content = UNMutableNotificationContent() let isGroupConversation = context.isGroupConversation @@ -194,9 +195,9 @@ struct CallNotificationBuilder: NotificationBuilder { return content } - + // MARK: - Helpers - + private func makeTitle() -> String? { let isGroupConversation = context.isGroupConversation let teamName = context.teamName @@ -235,11 +236,11 @@ struct CallNotificationBuilder: NotificationBuilder { case .unhandled: fatalError() } - + let notificationSoundName = UNNotificationSoundName(notificationSound.rawValue) return UNNotificationSound(named: notificationSoundName) } - + private func makeCategory() -> String { switch context.callState { case .incomingCall: diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift index 563ede83cc8..caefc468d1e 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/ConversationNotificationBuilder.swift @@ -21,7 +21,7 @@ import WireDataModel import WireLogging struct ConversationNotificationBuilder: NotificationBuilder { - + private struct Context { let senderID: UserID let conversationID: ConversationID @@ -80,56 +80,56 @@ struct ConversationNotificationBuilder: NotificationBuilder { switch event { case let .mlsMessageAdd(mlsMessageEvent): let decryptedMessage = mlsMessageEvent.decryptedMessages.first?.message - + guard let genericMessage = getGenericMessage( decryptedMessage: decryptedMessage ) else { return UNMutableNotificationContent() } - + if genericMessage.hasCalling { - + guard let callBuilder = await makeCallBuilder( calling: genericMessage.calling, at: context.eventTimeStamp ) else { return UNMutableNotificationContent() } - + builder = callBuilder - + } else { - + 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 genericMessage = getGenericMessage( decryptedMessage: decryptedMessage, externalMessage: externalEncryptedMessage ) else { return UNMutableNotificationContent() } if genericMessage.hasCalling { - + guard let callBuilder = await makeCallBuilder( calling: genericMessage.calling, at: context.eventTimeStamp ) else { return UNMutableNotificationContent() } - + builder = callBuilder - + } else { - + builder = await NewMessageNotificationBuilder( message: genericMessage, conversationID: proteusMessageEvent.conversationID, senderID: proteusMessageEvent.senderID ) - + } default: // TODO: [WPB-11175] - Generate notifications for other events @@ -158,9 +158,9 @@ struct ConversationNotificationBuilder: NotificationBuilder { return true } - + // MARK: - Helpers - + private func makeCallBuilder( calling: Calling, at date: Date? @@ -169,7 +169,7 @@ struct ConversationNotificationBuilder: NotificationBuilder { calling: calling, at: date ) - + // Checking early on that the builder should actually build the `CallKit` notification // if not we fallback to the regular call notification builder. if let callKitBuilder, await callKitBuilder.shouldBuildNotification() { @@ -183,7 +183,7 @@ struct ConversationNotificationBuilder: NotificationBuilder { return nil } } - + private func makeCallKitNotificationBuilder( calling: Calling, at date: Date? @@ -196,10 +196,10 @@ struct ConversationNotificationBuilder: NotificationBuilder { ) else { return nil } - + return callKitNotifBuilder } - + private func makeCallRegularNotificationBuilder( calling: Calling, at date: Date? @@ -212,10 +212,10 @@ struct ConversationNotificationBuilder: NotificationBuilder { ) else { return nil } - + return callNotifBuilder } - + private func getGenericMessage( decryptedMessage: String?, externalMessage: String? = nil @@ -225,8 +225,8 @@ struct ConversationNotificationBuilder: NotificationBuilder { from: decryptedMessage, externalData: externalMessage ) else { return nil } - + return genericMessage } - + } diff --git a/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift index 731618145c5..785b8c2172a 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift @@ -24,11 +24,11 @@ struct CallNotificationBodyComposer { // TODO: [WPB-15153] - Localize strings func make() -> String { switch format { - case .isCalling(let senderName): + case let .isCalling(senderName): senderName != nil ? "\(senderName!) is calling" : "Incoming call" - case .isCallingWithVideo(let senderName): + case let .isCallingWithVideo(senderName): senderName != nil ? "\(senderName!) is calling with video" : "Incoming video call" - case .called(let senderName): + case let .called(senderName): senderName != nil ? "\(senderName!) called" : "Missed call" } } diff --git a/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift index 8e434e63edc..2bd1edb1864 100644 --- a/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift +++ b/WireDomain/Sources/WireDomain/Notifications/NotificationBody.swift @@ -32,14 +32,14 @@ enum NotificationBody { ) return newMessageBodyComposer.make() - + case let .call(callBodyFormat): let callBodyComposer = CallNotificationBodyComposer( format: callBodyFormat ) - + return callBodyComposer.make() - + case let .bundled(count): return "\(count) new messages." } @@ -78,7 +78,7 @@ extension NotificationBody { /// `New message` case hidden } - + /// The expected formats for the body of a call notification. enum CallBodyFormat { /// `[sender name] is calling` or `Incoming call` if sender is nil. diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift index fa7c152f248..7052b7a9b7e 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/ConversationLocalStore.swift @@ -419,7 +419,7 @@ public protocol ConversationLocalStoreProtocol { ) async -> Bool func shouldHideNotification() async -> Bool - + func conversationNeedsBackendUpdate( _ conversation: ZMConversation ) async -> Bool @@ -886,7 +886,7 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { ) } } - + public func conversationNeedsBackendUpdate( _ conversation: ZMConversation ) async -> Bool { diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift index 04e1f271ec0..c88e2480409 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift @@ -70,9 +70,9 @@ final class CallKitNotificationBuilderTests: XCTestCase { self.userLocalStore } } - + func testGenerateCallKitNotification_Is_Group_Conversation_And_Is_Team_User() async throws { - + // Mock let isGroup = true @@ -80,11 +80,11 @@ final class CallKitNotificationBuilderTests: XCTestCase { await setupMock(isGroup: isGroup, isTeam: isTeam) let callKitTestUsecases = getCallKitTestUseCases() - + for callKitTestUsecase in callKitTestUsecases { var calling = Calling() calling.content = callKitTestUsecase.json - + sut = await CallKitNotificationBuilder( calling: calling, conversationID: Scaffolding.conversationID, @@ -93,12 +93,12 @@ final class CallKitNotificationBuilderTests: XCTestCase { userDefaults: defaults, callKitReporting: MockCallKitReporting.self ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + _ = await sut.buildContent() - + try await internalTest_assertNotificationContent( callKitTestUsecase: callKitTestUsecase, isGroup: isGroup, @@ -106,9 +106,9 @@ final class CallKitNotificationBuilderTests: XCTestCase { ) } } - + func testGenerateCallKitNotification_Is_Group_Conversation_And_Is_Personal_User() async throws { - + // Mock let isGroup = true @@ -116,11 +116,11 @@ final class CallKitNotificationBuilderTests: XCTestCase { await setupMock(isGroup: isGroup, isTeam: isTeam) let callKitTestUsecases = getCallKitTestUseCases() - + for callKitTestUsecase in callKitTestUsecases { var calling = Calling() calling.content = callKitTestUsecase.json - + sut = await CallKitNotificationBuilder( calling: calling, conversationID: Scaffolding.conversationID, @@ -129,12 +129,12 @@ final class CallKitNotificationBuilderTests: XCTestCase { userDefaults: defaults, callKitReporting: MockCallKitReporting.self ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + _ = await sut.buildContent() - + try await internalTest_assertNotificationContent( callKitTestUsecase: callKitTestUsecase, isGroup: isGroup, @@ -142,9 +142,9 @@ final class CallKitNotificationBuilderTests: XCTestCase { ) } } - + func testGenerateCallKitNotification_Is_OneOnOne_Conversation_And_Team() async throws { - + // Mock let isGroup = false @@ -152,11 +152,11 @@ final class CallKitNotificationBuilderTests: XCTestCase { await setupMock(isGroup: isGroup, isTeam: isTeam) let callKitTestUsecases = getCallKitTestUseCases() - + for callKitTestUsecase in callKitTestUsecases { var calling = Calling() calling.content = callKitTestUsecase.json - + sut = await CallKitNotificationBuilder( calling: calling, conversationID: Scaffolding.conversationID, @@ -165,12 +165,12 @@ final class CallKitNotificationBuilderTests: XCTestCase { userDefaults: defaults, callKitReporting: MockCallKitReporting.self ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + _ = await sut.buildContent() - + try await internalTest_assertNotificationContent( callKitTestUsecase: callKitTestUsecase, isGroup: isGroup, @@ -178,28 +178,28 @@ final class CallKitNotificationBuilderTests: XCTestCase { ) } } - + func testGenerateCallKitNotification_Should_Build_Notification_Returns_False() async { // Mock let isGroup = false let isTeam = true - + await setupMock(isGroup: isGroup, isTeam: isTeam) - + // An example payload that will be treated as an `unhandled` case. let unhandledCallJson = """ - { - "type": "REMOTEMUTE", - "src_clientid": "clientid", - "resp": false, - "props": { "videosend": "false" } - } - """ - + { + "type": "REMOTEMUTE", + "src_clientid": "clientid", + "resp": false, + "props": { "videosend": "false" } + } + """ + var calling = Calling() calling.content = unhandledCallJson - + sut = await CallKitNotificationBuilder( calling: calling, conversationID: Scaffolding.conversationID, @@ -208,88 +208,88 @@ final class CallKitNotificationBuilderTests: XCTestCase { userDefaults: defaults, callKitReporting: MockCallKitReporting.self ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, false) } - + private func internalTest_assertNotificationContent( callKitTestUsecase: CallKitTestUseCase, isGroup: Bool, isTeam: Bool ) async throws { let callKitPayload = try XCTUnwrap(MockCallKitReporting.payload) - + XCTAssertEqual(callKitPayload["accountID"] as! String, Scaffolding.accountID.uuidString) XCTAssertEqual(callKitPayload["conversationID"] as! String, Scaffolding.conversationID.uuid.uuidString) - + switch callKitTestUsecase { - case .incomingAudioCall(let string): + case let .incomingAudioCall(string): XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, true) XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, false) - case .incomingVideoCall(let string): + case let .incomingVideoCall(string): XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, true) XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, true) - case .endingCall(let string): + case let .endingCall(string): XCTAssertEqual(callKitPayload["shouldRing"] as! Bool, false) XCTAssertEqual(callKitPayload["hasVideo"] as! Bool, false) } - + let callerName = if isGroup { isTeam ? "\(Scaffolding.conversationName) in \(Scaffolding.teamName)" : - "\(Scaffolding.conversationName)" - + "\(Scaffolding.conversationName)" + } else { isTeam ? "\(Scaffolding.senderName) in \(Scaffolding.teamName)" : "\(Scaffolding.senderName)" } - + XCTAssertEqual(callKitPayload["callerName"] as! String, callerName) } - + // MARK: - Tested use cases - + private enum CallKitTestUseCase { case incomingAudioCall(String) case incomingVideoCall(String) case endingCall(String) - + var json: String { switch self { - case .incomingAudioCall(let string): + case let .incomingAudioCall(string): string - case .incomingVideoCall(let string): + case let .incomingVideoCall(string): string - case .endingCall(let string): + case let .endingCall(string): string } } } - + private func getCallKitTestUseCases() -> [CallKitTestUseCase] { let startAudioCallJson = setupCallingContentMock(type: "SETUP") let startVideoCallJson = setupCallingContentMock(type: "SETUP", isVideo: true) let endCallJson = setupCallingContentMock(type: "CANCEL") - + return [ .incomingAudioCall(startAudioCallJson), .incomingVideoCall(startVideoCallJson), .endingCall(endCallJson) ] - + } - + // MARK: - Mocks - + struct MockCallKitReporting: CallKitReporting { nonisolated(unsafe) static var payload: [AnyHashable: Any]! - - static func reportIncomingCall(payload: [AnyHashable : Any]) async throws { + + static func reportIncomingCall(payload: [AnyHashable: Any]) async throws { Self.payload = payload } } - + private func setupCallingContentMock( type: String, isVideo: Bool = false @@ -303,16 +303,16 @@ final class CallKitNotificationBuilderTests: XCTestCase { } """ } - + private func setupMock( isGroup: Bool, isTeam: Bool ) async { - + defaults.set(true, forKey: "isAVSReady") defaults.set(true, forKey: "isCallKitAvailable") defaults.set([Scaffolding.accountID.uuidString], forKey: "loadedUserSessions") - + let conversation = await context.perform { [self] in modelHelper.createGroupConversation(in: context) } @@ -335,9 +335,9 @@ final class CallKitNotificationBuilderTests: XCTestCase { userLocalStore.teamNameFor_MockValue = .some(isTeam ? Scaffolding.teamName : nil) conversationLocalStore.shouldHideNotification_MockValue = false } - + private enum Scaffolding { - + static let senderName = "User1" static let conversationName = "Conversation1" static let teamName = "Team1" @@ -345,6 +345,6 @@ final class CallKitNotificationBuilderTests: XCTestCase { static let userID = UserID(uuid: .mockID3, domain: "domain.com") static let accountID = UUID.mockID10 } - + } diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift index 395884cfc89..cc65a486cfe 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift @@ -66,9 +66,9 @@ final class CallNotificationBuilderTests: XCTestCase { self.userLocalStore } } - + func testGenerateCallNotification_Is_Group_Conversation_And_Is_Team_User() async throws { - + // Mock let isGroup = true @@ -76,23 +76,23 @@ final class CallNotificationBuilderTests: XCTestCase { await setupMock(isGroup: isGroup, isTeam: isTeam) let callingTestUsecases = getCallingTestUseCases() - + for callingTestUsecase in callingTestUsecases { var calling = Calling() calling.content = callingTestUsecase.json - + sut = await CallNotificationBuilder( calling: calling, at: .now, conversationID: Scaffolding.conversationID, senderID: Scaffolding.userID ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + let content = await sut.buildContent() - + try await internalTest_assertNotificationContent( content, callingTestUsecase: callingTestUsecase, @@ -101,9 +101,9 @@ final class CallNotificationBuilderTests: XCTestCase { ) } } - + func testGenerateCallNotification_Is_Group_Conversation_And_Is_Personal_User() async throws { - + // Mock let isGroup = true @@ -111,23 +111,23 @@ final class CallNotificationBuilderTests: XCTestCase { await setupMock(isGroup: isGroup, isTeam: isTeam) let callingTestUsecases = getCallingTestUseCases() - + for callingTestUsecase in callingTestUsecases { var calling = Calling() calling.content = callingTestUsecase.json - + sut = await CallNotificationBuilder( calling: calling, at: .now, conversationID: Scaffolding.conversationID, senderID: Scaffolding.userID ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + let content = await sut.buildContent() - + try await internalTest_assertNotificationContent( content, callingTestUsecase: callingTestUsecase, @@ -135,35 +135,35 @@ final class CallNotificationBuilderTests: XCTestCase { isTeam: isTeam ) } - + } - + func testGenerateCallNotification_Is_OneOnOne_Conversation_And_Team() async throws { - + // Mock let isGroup = false let isTeam = true - + await setupMock(isGroup: isGroup, isTeam: isTeam) let callingTestUsecases = getCallingTestUseCases() - + for callingTestUsecase in callingTestUsecases { var calling = Calling() calling.content = callingTestUsecase.json - + sut = await CallNotificationBuilder( calling: calling, at: .now, conversationID: Scaffolding.conversationID, senderID: Scaffolding.userID ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, true) - + let content = await sut.buildContent() - + try await internalTest_assertNotificationContent( content, callingTestUsecase: callingTestUsecase, @@ -171,42 +171,42 @@ final class CallNotificationBuilderTests: XCTestCase { isTeam: isTeam ) } - + } - + func testGenerateCallNotification_Should_Build_Notification_Returns_False() async { // Mock let isGroup = false let isTeam = true - + await setupMock(isGroup: isGroup, isTeam: isTeam) - + // An example payload that will be treated as an `unhandled` case. let unhandledCallJson = """ - { - "type": "REJECT", - "src_clientid": "clientid", - "resp": true, - "props": { "videosend": "false" } - } - """ - - + { + "type": "REJECT", + "src_clientid": "clientid", + "resp": true, + "props": { "videosend": "false" } + } + """ + + var calling = Calling() calling.content = unhandledCallJson - + sut = await CallNotificationBuilder( calling: calling, at: .now, conversationID: Scaffolding.conversationID, senderID: Scaffolding.userID ) - + let shouldBuildNotification = await sut.shouldBuildNotification() XCTAssertEqual(shouldBuildNotification, false) } - + private func internalTest_assertNotificationContent( _ notificationContent: UNMutableNotificationContent, callingTestUsecase: CallingTestUseCase, @@ -262,7 +262,7 @@ final class CallNotificationBuilderTests: XCTestCase { } // Sound - + switch callingTestUsecase { case .incomingAudioCall, .incomingVideoCall: XCTAssertEqual( @@ -275,54 +275,54 @@ final class CallNotificationBuilderTests: XCTestCase { UNNotificationSound(named: .init("default")) ) } - + // Thread ID XCTAssertEqual( notificationContent.threadIdentifier, Scaffolding.conversationID.uuid.uuidString.lowercased() ) - + // User info XCTAssertEqual(notificationContent.userInfo["selfUserIDString"] as! UUID, .mockID1) XCTAssertNil(notificationContent.userInfo["senderIDString"]) XCTAssertEqual(notificationContent.userInfo["conversationIDString"] as! UUID, .mockID2) } - + // MARK: - Tested use cases - + private enum CallingTestUseCase { case incomingAudioCall(String) case incomingVideoCall(String) case missedCall(String) - + var json: String { switch self { - case .incomingAudioCall(let string): + case let .incomingAudioCall(string): string - case .incomingVideoCall(let string): + case let .incomingVideoCall(string): string - case .missedCall(let string): + case let .missedCall(string): string } } } - + private func getCallingTestUseCases() -> [CallingTestUseCase] { let startAudioCallJson = setupCallingContentMock(type: "SETUP") let startVideoCallJson = setupCallingContentMock(type: "SETUP", isVideo: true) let endCallJson = setupCallingContentMock(type: "CANCEL") - + return [ .incomingAudioCall(startAudioCallJson), .incomingVideoCall(startVideoCallJson), .missedCall(endCallJson) ] - + } - + // MARK: - Mocks - + private func setupCallingContentMock( type: String, isVideo: Bool = false @@ -336,7 +336,7 @@ final class CallNotificationBuilderTests: XCTestCase { } """ } - + private func setupMock(isGroup: Bool, isTeam: Bool) async { let conversation = await context.perform { [self] in modelHelper.createGroupConversation(in: context) @@ -358,7 +358,7 @@ final class CallNotificationBuilderTests: XCTestCase { userLocalStore.teamNameFor_MockValue = .some(isTeam ? Scaffolding.teamName : nil) conversationLocalStore.shouldHideNotification_MockValue = false } - + private enum Scaffolding { static let senderName = "User1" static let conversationName = "Conversation1" @@ -366,5 +366,5 @@ final class CallNotificationBuilderTests: XCTestCase { static let conversationID = WireAPI.QualifiedID(uuid: .mockID2, domain: "domain.com") static let userID = UserID(uuid: .mockID3, domain: "domain.com") } - + } From 834df05feb68d052ecb35b05ac5a758108a17aa8 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:26:31 +0100 Subject: [PATCH 3/3] remove unused struct property --- .../Builders/Call/CallKitNotificationBuilder.swift | 7 ------- .../Composers/CallNotificationBodyComposer.swift | 1 - .../Notifications/CallKitNotificationBuilderTests.swift | 2 -- .../Notifications/CallNotificationBuilderTests.swift | 1 - 4 files changed, 11 deletions(-) diff --git a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift index d82cf10f787..f5217cdfe81 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Builders/Call/CallKitNotificationBuilder.swift @@ -74,7 +74,6 @@ struct CallKitNotificationBuilder: NotificationBuilder { let isAVSReady: Bool let isCallKitReady: Bool let isUserSessionLoaded: Bool - let wasCallHandleReported: Bool let isCallerSelf: Bool let isCallStateValid: Bool @@ -147,7 +146,6 @@ struct CallKitNotificationBuilder: NotificationBuilder { isAVSReady: isAVSReady, isCallKitReady: isCallKitReady, isUserSessionLoaded: isUserSessionLoaded, - wasCallHandleReported: wasCallHandleReported, isCallerSelf: selfUser == caller, isCallStateValid: callKitState != .unhandled ) @@ -173,7 +171,6 @@ struct CallKitNotificationBuilder: NotificationBuilder { self.callKitReporter = callKitReporting } - func shouldBuildNotification() async -> Bool { validator.validate() } @@ -233,7 +230,3 @@ struct CallKitNotificationBuilder: NotificationBuilder { } } - - - - diff --git a/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift index 785b8c2172a..98221abf04f 100644 --- a/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift +++ b/WireDomain/Sources/WireDomain/Notifications/Composers/CallNotificationBodyComposer.swift @@ -33,4 +33,3 @@ struct CallNotificationBodyComposer { } } } - diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift index c88e2480409..d342ffcfa0e 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallKitNotificationBuilderTests.swift @@ -266,7 +266,6 @@ final class CallKitNotificationBuilderTests: XCTestCase { } } - private func getCallKitTestUseCases() -> [CallKitTestUseCase] { let startAudioCallJson = setupCallingContentMock(type: "SETUP") let startVideoCallJson = setupCallingContentMock(type: "SETUP", isVideo: true) @@ -347,4 +346,3 @@ final class CallKitNotificationBuilderTests: XCTestCase { } } - diff --git a/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift index cc65a486cfe..b152b87237a 100644 --- a/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift +++ b/WireDomain/Tests/WireDomainTests/Notifications/CallNotificationBuilderTests.swift @@ -192,7 +192,6 @@ final class CallNotificationBuilderTests: XCTestCase { } """ - var calling = Calling() calling.content = unhandledCallJson