diff --git a/Podfile.lock b/Podfile.lock index 3ee64ac5a..30631cbff 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -133,4 +133,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2c877a533db6e82eaa94407c95be114d80c2f893 -COCOAPODS: 1.15.0 +COCOAPODS: 1.14.3 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e75211db9..d080cc0a8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -2807,11 +2807,11 @@ C300A5BB2554AFFB00555489 /* Messages */ = { isa = PBXGroup; children = ( + C300A5C62554B02D00555489 /* Visible Messages */, + C300A5C72554B03900555489 /* Control Messages */, C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */, - C300A5C62554B02D00555489 /* Visible Messages */, - C300A5C72554B03900555489 /* Control Messages */, ); path = Messages; sourceTree = ""; @@ -7147,6 +7147,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SignalUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -7291,6 +7292,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionSnodeKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -7592,6 +7594,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = NO; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_INCLUDE_PATHS = "$(inherited) \"${PODS_XCFRAMEWORKS_BUILD_DIR}/Clibsodium\" \"$(TARGET_BUILD_DIR)/libSessionUtil\""; @@ -7697,6 +7700,7 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_INCLUDE_PATHS = "$(inherited) \"${PODS_XCFRAMEWORKS_BUILD_DIR}/Clibsodium\" \"$(TARGET_BUILD_DIR)/libSessionUtil\""; @@ -7921,6 +7925,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; + STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "Session/Meta/Signal-Bridging-Header.h"; SWIFT_OBJC_INTERFACE_HEADER_NAME = "Session-Swift.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index 850c2ae8b..371fe94de 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -221,17 +221,14 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle case .finished: break case .failure(let error): modalActivityIndicator.dismiss { - var messageOrNil: String? - if let error = error as? SnodeAPIError { - switch error { - case .decryptionFailed, .hashingFailed, .validationFailed: - messageOrNil = error.errorDescription - default: break - } - } let message: String = { - if let messageOrNil: String = messageOrNil { - return messageOrNil + if let error = error as? SnodeAPIError { + switch error { + case .decryptionFailed, .hashingFailed, .validationFailed: + return "\(error)" + + default: break + } } return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ? diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 99a7a6bbd..021d7dc33 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -63,8 +63,8 @@ public enum AttachmentUploadJob: JobExecutor { MessageSender.handleMessageWillSend( db, message: details.message, - interactionId: interactionId, - isSyncMessage: details.isSyncMessage + destination: details.destination, + interactionId: interactionId ) } @@ -93,9 +93,9 @@ public enum AttachmentUploadJob: JobExecutor { MessageSender.handleFailedMessageSend( db, message: details.message, + destination: nil, with: .other(error), interactionId: interactionId, - isSyncMessage: details.isSyncMessage, using: dependencies ) } diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 65d2601d6..4c6e61c58 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -51,7 +51,6 @@ public enum GroupLeavingJob: JobExecutor { to: destination, namespace: destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: false, using: dependencies ) } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 66da9eca4..cb463a54b 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -176,7 +176,6 @@ public enum MessageSendJob: JobExecutor { to: details.destination, namespace: details.destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: details.isSyncMessage, using: dependencies ) } @@ -197,7 +196,7 @@ public enum MessageSendJob: JobExecutor { SNLog("[MessageSendJob] Couldn't send message due to error: \(error) (paths: \(OnionRequestAPI.paths.prettifiedDescription)).") default: - SNLog("[MessageSendJob] Couldn't send message due to error: \(error).") + SNLog("[MessageSendJob] Couldn't send message due to error: \(error)") } // Actual error handling @@ -240,25 +239,22 @@ extension MessageSendJob { private enum CodingKeys: String, CodingKey { case destination case message - case isSyncMessage + @available(*, deprecated, message: "replaced by 'Message.Destination.syncMessage'") case isSyncMessage case variant } public let destination: Message.Destination public let message: Message - public let isSyncMessage: Bool public let variant: Message.Variant? // MARK: - Initialization public init( destination: Message.Destination, - message: Message, - isSyncMessage: Bool = false + message: Message ) { self.destination = destination self.message = message - self.isSyncMessage = isSyncMessage self.variant = Message.Variant(from: message) } @@ -272,10 +268,42 @@ extension MessageSendJob { throw StorageError.decodingFailed } + let message: Message = try variant.decode(from: container, forKey: .message) + var destination: Message.Destination = try container.decode(Message.Destination.self, forKey: .destination) + + /// Handle the legacy 'isSyncMessage' flag - this flag was deprecated in `2.5.2` (April 2024) and can be removed in a + /// subsequent release after May 2024 + if ((try? container.decode(Bool.self, forKey: .isSyncMessage)) ?? false) { + switch (destination, message) { + case (.contact, let message as VisibleMessage): + guard let targetPublicKey: String = message.syncTarget else { + SNLog("Unable to decode messageSend job due to missing syncTarget") + throw StorageError.decodingFailed + } + + destination = .syncMessage(originalRecipientPublicKey: targetPublicKey) + + case (.contact, let message as ExpirationTimerUpdate): + guard let targetPublicKey: String = message.syncTarget else { + SNLog("Unable to decode messageSend job due to missing syncTarget") + throw StorageError.decodingFailed + } + + destination = .syncMessage(originalRecipientPublicKey: targetPublicKey) + + case (.contact(let publicKey), _): + SNLog("Sync message in messageSend job was missing explicit syncTarget (falling back to specified value)") + destination = .syncMessage(originalRecipientPublicKey: publicKey) + + default: + SNLog("Unable to decode messageSend job due to invalid sync message state") + throw StorageError.decodingFailed + } + } + self = Details( - destination: try container.decode(Message.Destination.self, forKey: .destination), - message: try variant.decode(from: container, forKey: .message), - isSyncMessage: ((try? container.decode(Bool.self, forKey: .isSyncMessage)) ?? false) + destination: destination, + message: message ) } @@ -289,7 +317,6 @@ extension MessageSendJob { try container.encode(destination, forKey: .destination) try container.encode(message, forKey: .message) - try container.encode(isSyncMessage, forKey: .isSyncMessage) try container.encode(variant, forKey: .variant) } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 8b5090afb..1b6a0485f 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -43,8 +43,7 @@ public enum SendReadReceiptsJob: JobExecutor { ), to: details.destination, namespace: details.destination.defaultNamespace, - interactionId: nil, - isSyncMessage: false + interactionId: nil ) } .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 6dbc8aeec..52c228007 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -7,8 +7,16 @@ import SessionUtilitiesKit public extension Message { enum Destination: Codable, Hashable { + /// A message directed to another user case contact(publicKey: String) + + /// A message that was originally sent to another user but needs to be replicated to the current users swarm + case syncMessage(originalRecipientPublicKey: String) + + /// A message directed to group conversation case closedGroup(groupPublicKey: String) + + /// A message directed to an open group case openGroup( roomToken: String, server: String, @@ -16,13 +24,15 @@ public extension Message { whisperMods: Bool = false, fileIds: [String]? = nil ) + + /// A message directed to an open group inbox case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) public var defaultNamespace: SnodeAPI.Namespace? { switch self { - case .contact: return .`default` + case .contact, .syncMessage: return .`default` case .closedGroup: return .legacyClosedGroup - default: return nil + case .openGroup, .openGroupInbox: return nil } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index b4b244034..756378421 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -246,7 +246,7 @@ public extension Message { static func threadId(forMessage message: Message, destination: Message.Destination) -> String { switch destination { - case .contact(let publicKey): + case .contact(let publicKey), .syncMessage(let publicKey): // Extract the 'syncTarget' value if there is one let maybeSyncTarget: String? @@ -661,29 +661,24 @@ public extension Message { internal static func getSpecifiedTTL( message: Message, - isGroupMessage: Bool, - isSyncMessage: Bool + destination: Message.Destination ) -> UInt64 { guard Features.useNewDisappearingMessagesConfig else { return message.ttl } - // Not disappearing messages - guard let expiresInSeconds = message.expiresInSeconds else { return message.ttl } - // Sync message should be read already, it is the same for disappear after read and disappear after sent - guard !isSyncMessage else { return UInt64(expiresInSeconds * 1000) } - - // Disappear after read messages that have not be read - guard let expiresStartedAtMs = message.expiresStartedAtMs else { return message.ttl } - - // Disappear after read messages that have already be read - guard message.sentTimestamp == UInt64(expiresStartedAtMs) else { return message.ttl } - - // Disappear after sent messages with exceptions - switch message { - case is ClosedGroupControlMessage, is UnsendRequest: + switch (destination, message) { + // Disappear after sent messages with exceptions + case (_, is UnsendRequest): return message.ttl + case (.closedGroup, is ClosedGroupControlMessage), (.closedGroup, is ExpirationTimerUpdate): return message.ttl - case is ExpirationTimerUpdate: - return isGroupMessage ? message.ttl : UInt64(expiresInSeconds * 1000) + default: + guard + let expiresInSeconds = message.expiresInSeconds, // Not disappearing messages + expiresInSeconds > 0, // Not disappearing messages (0 == disabled) + let expiresStartedAtMs = message.expiresStartedAtMs, // Unread disappear after read message + message.sentTimestamp == UInt64(expiresStartedAtMs) // Already read disappear after read message + else { return message.ttl } + return UInt64(expiresInSeconds * 1000) } } diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index 38c499f36..67acc770d 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -4,7 +4,7 @@ import Foundation -public enum MessageSenderError: LocalizedError, Equatable { +public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case invalidMessage case protoConversionFailed case noUserX25519KeyPair @@ -33,24 +33,28 @@ public enum MessageSenderError: LocalizedError, Equatable { } } - public var errorDescription: String? { + public var description: String { switch self { - case .invalidMessage: return "Invalid message." - case .protoConversionFailed: return "Couldn't convert message to proto." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." - case .signingFailed: return "Couldn't sign message." - case .encryptionFailed: return "Couldn't encrypt message." - case .noUsername: return "Missing username." - case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded." - case .blindingFailed: return "Couldn't blind the sender" - case .sendJobTimeout: return "Send job timeout (likely due to path building taking too long)." + case .invalidMessage: return "Invalid message (MessageSenderError.invalidMessage)." + case .protoConversionFailed: return "Couldn't convert message to proto (MessageSenderError.protoConversionFailed)." + case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair (MessageSenderError.noUserX25519KeyPair)." + case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair (MessageSenderError.noUserED25519KeyPair)." + case .signingFailed: return "Couldn't sign message (MessageSenderError.signingFailed)." + case .encryptionFailed: return "Couldn't encrypt message (MessageSenderError.encryptionFailed)." + case .noUsername: return "Missing username (MessageSenderError.noUsername)." + case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded (MessageSenderError.attachmentsNotUploaded)." + case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." + case .sendJobTimeout: return "Send job timeout (likely due to path building taking too long - MessageSenderError.sendJobTimeout)." // Closed groups - case .noThread: return "Couldn't find a thread associated with the given group public key." - case .noKeyPair: return "Couldn't find a private key associated with the given group public key." - case .invalidClosedGroupUpdate: return "Invalid group update." - case .other(let error): return error.localizedDescription + case .noThread: return "Couldn't find a thread associated with the given group public key (MessageSenderError.noThread)." + case .noKeyPair: return "Couldn't find a private key associated with the given group public key (MessageSenderError.noKeyPair)." + case .invalidClosedGroupUpdate: return "Invalid group update (MessageSenderError.invalidClosedGroupUpdate)." + case .other(let error): + switch error { + case is CustomStringConvertible: return "\(error)" + default: return error.localizedDescription + } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 5ca3e6333..8b1a35e0e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -70,7 +70,6 @@ extension MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: false, using: dependencies ) return @@ -84,8 +83,7 @@ extension MessageSender { interactionId: interactionId, details: MessageSendJob.Details( destination: destination, - message: message, - isSyncMessage: isSyncMessage + message: message ) ), canStartJob: true, @@ -132,6 +130,7 @@ extension MessageSender { let threadId: String = { switch preparedSendData.destination { case .contact(let publicKey): return publicKey + case .syncMessage(let originalRecipientPublicKey): return originalRecipientPublicKey case .closedGroup(let groupPublicKey): return groupPublicKey case .openGroup(let roomToken, let server, _, _, _): return OpenGroup.idFor(roomToken: roomToken, server: server) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 430bbdda8..b80882804 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -17,7 +17,6 @@ public final class MessageSender { let message: Message? let interactionId: Int64? - let isSyncMessage: Bool? let totalAttachmentsUploaded: Int let snodeMessage: SnodeMessage? @@ -30,7 +29,6 @@ public final class MessageSender { destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - isSyncMessage: Bool?, totalAttachmentsUploaded: Int = 0, snodeMessage: SnodeMessage?, plaintext: Data?, @@ -42,7 +40,6 @@ public final class MessageSender { self.destination = destination self.namespace = namespace self.interactionId = interactionId - self.isSyncMessage = isSyncMessage self.totalAttachmentsUploaded = totalAttachmentsUploaded self.snodeMessage = snodeMessage @@ -56,7 +53,6 @@ public final class MessageSender { destination: Message.Destination, namespace: SnodeAPI.Namespace, interactionId: Int64?, - isSyncMessage: Bool?, snodeMessage: SnodeMessage ) { self.shouldSend = true @@ -65,7 +61,6 @@ public final class MessageSender { self.destination = destination self.namespace = namespace self.interactionId = interactionId - self.isSyncMessage = isSyncMessage self.totalAttachmentsUploaded = 0 self.snodeMessage = snodeMessage @@ -86,7 +81,6 @@ public final class MessageSender { self.destination = destination self.namespace = nil self.interactionId = interactionId - self.isSyncMessage = false self.totalAttachmentsUploaded = 0 self.snodeMessage = nil @@ -107,7 +101,6 @@ public final class MessageSender { self.destination = destination self.namespace = nil self.interactionId = interactionId - self.isSyncMessage = false self.totalAttachmentsUploaded = 0 self.snodeMessage = nil @@ -124,7 +117,6 @@ public final class MessageSender { destination: destination.with(fileIds: fileIds), namespace: namespace, interactionId: interactionId, - isSyncMessage: isSyncMessage, totalAttachmentsUploaded: fileIds.count, snodeMessage: snodeMessage, plaintext: plaintext, @@ -139,7 +131,6 @@ public final class MessageSender { to destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - isSyncMessage: Bool = false, using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { // Common logic for all destinations @@ -154,7 +145,7 @@ public final class MessageSender { ) switch destination { - case .contact, .closedGroup: + case .contact, .syncMessage, .closedGroup: return try prepareSendToSnodeDestination( db, message: updatedMessage, @@ -163,7 +154,6 @@ public final class MessageSender { interactionId: interactionId, userPublicKey: currentUserPublicKey, messageSendTimestamp: messageSendTimestamp, - isSyncMessage: isSyncMessage, using: dependencies ) @@ -198,13 +188,13 @@ public final class MessageSender { interactionId: Int64?, userPublicKey: String, messageSendTimestamp: Int64, - isSyncMessage: Bool = false, using dependencies: Dependencies ) throws -> PreparedSendData { message.sender = userPublicKey message.recipient = { switch destination { case .contact(let publicKey): return publicKey + case .syncMessage: return userPublicKey case .closedGroup(let groupPublicKey): return groupPublicKey case .openGroup, .openGroupInbox: preconditionFailure() } @@ -215,6 +205,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .invalidMessage, interactionId: interactionId, using: dependencies @@ -223,25 +214,25 @@ public final class MessageSender { // Attach the user's profile if needed (no need to do so for 'Note to Self' or sync // messages as they will be managed by the user config handling - let isSelfSend: Bool = (message.recipient == userPublicKey) - - if !isSelfSend, !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { - let profile: Profile = Profile.fetchOrCreateCurrentUser(db) - - if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { - messageWithProfile.profile = VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profileKey, - profilePictureUrl: profilePictureUrl - ) - } - else { - messageWithProfile.profile = VisibleMessage.VMProfile(displayName: profile.name) - } + switch (destination, (message.recipient == userPublicKey), message as? MessageWithProfile) { + case (.syncMessage, _, _), (_, true, _), (_, _, .none): break + case (_, _, .some(var messageWithProfile)): + let profile: Profile = Profile.fetchOrCreateCurrentUser(db) + + if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { + messageWithProfile.profile = VisibleMessage.VMProfile( + displayName: profile.name, + profileKey: profileKey, + profilePictureUrl: profilePictureUrl + ) + } + else { + messageWithProfile.profile = VisibleMessage.VMProfile(displayName: profile.name) + } } // Perform any pre-send actions - handleMessageWillSend(db, message: message, interactionId: interactionId, isSyncMessage: isSyncMessage) + handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) // Convert it to protobuf let threadId: String = Message.threadId(forMessage: message, destination: destination) @@ -250,6 +241,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .protoConversionFailed, interactionId: interactionId, using: dependencies @@ -267,6 +259,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -280,6 +273,9 @@ public final class MessageSender { case .contact(let publicKey): ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey, using: dependencies) + case .syncMessage: + ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: userPublicKey, using: dependencies) + case .closedGroup(let groupPublicKey): guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { throw MessageSenderError.noKeyPair @@ -300,6 +296,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -311,7 +308,7 @@ public final class MessageSender { let senderPublicKey: String switch destination { - case .contact: + case .contact, .syncMessage: kind = .sessionMessage senderPublicKey = "" @@ -336,6 +333,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -350,13 +348,7 @@ public final class MessageSender { data: base64EncodedData, ttl: Message.getSpecifiedTTL( message: message, - isGroupMessage: { - switch destination { - case .closedGroup: return true - default: return false - } - }(), - isSyncMessage: isSyncMessage + destination: destination ), timestampMs: UInt64(messageSendTimestamp) ) @@ -366,7 +358,6 @@ public final class MessageSender { destination: destination, namespace: namespace, interactionId: interactionId, - isSyncMessage: isSyncMessage, snodeMessage: snodeMessage ) } @@ -382,7 +373,7 @@ public final class MessageSender { let threadId: String switch destination { - case .contact, .closedGroup, .openGroupInbox: preconditionFailure() + case .contact, .syncMessage, .closedGroup, .openGroupInbox: preconditionFailure() case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, _): threadId = OpenGroup.idFor(roomToken: roomToken, server: server) message.recipient = [ @@ -439,6 +430,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .invalidMessage, interactionId: interactionId, using: dependencies @@ -455,6 +447,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .noUsername, interactionId: interactionId, using: dependencies @@ -462,13 +455,14 @@ public final class MessageSender { } // Perform any pre-send actions - handleMessageWillSend(db, message: message, interactionId: interactionId) + handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) // Convert it to protobuf guard let proto = message.toProto(db, threadId: threadId) else { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .protoConversionFailed, interactionId: interactionId, using: dependencies @@ -486,6 +480,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -533,13 +528,14 @@ public final class MessageSender { } // Perform any pre-send actions - handleMessageWillSend(db, message: message, interactionId: interactionId) + handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) // Convert it to protobuf guard let proto = message.toProto(db, threadId: recipientBlindedPublicKey) else { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .protoConversionFailed, interactionId: interactionId, using: dependencies @@ -557,6 +553,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -580,6 +577,7 @@ public final class MessageSender { throw MessageSender.handleFailedMessageSend( db, message: message, + destination: destination, with: .other(error), interactionId: interactionId, using: dependencies @@ -628,9 +626,9 @@ public final class MessageSender { MessageSender.handleFailedMessageSend( db, message: message, + destination: data.destination, with: .attachmentsNotUploaded, interactionId: data.interactionId, - isSyncMessage: (data.isSyncMessage == true), using: dependencies ) } @@ -646,7 +644,7 @@ public final class MessageSender { } switch data.destination { - case .contact, .closedGroup: return sendToSnodeDestination(data: data, using: dependencies) + case .contact, .syncMessage, .closedGroup: return sendToSnodeDestination(data: data, using: dependencies) case .openGroup: return sendToOpenGroupDestination(data: data, using: dependencies) case .openGroupInbox: return sendToOpenGroupInbox(data: data, using: dependencies) } @@ -661,7 +659,6 @@ public final class MessageSender { guard let message: Message = data.message, let namespace: SnodeAPI.Namespace = data.namespace, - let isSyncMessage: Bool = data.isSyncMessage, let snodeMessage: SnodeMessage = data.snodeMessage else { return Fail(error: MessageSenderError.invalidMessage) @@ -680,9 +677,10 @@ public final class MessageSender { details: NotifyPushServerJob.Details(message: snodeMessage) ) let shouldNotify: Bool = { - switch updatedMessage { - case is VisibleMessage, is UnsendRequest: return !isSyncMessage - case let callMessage as CallMessage: + switch (updatedMessage, data.destination) { + case (is VisibleMessage, .syncMessage), (is UnsendRequest, .syncMessage): return false + case (is VisibleMessage, _), (is UnsendRequest, _): return true + case (let callMessage as CallMessage, _): // Note: Other 'CallMessage' types are too big to send as push notifications // so only send the 'preOffer' message as a notification switch callMessage.kind { @@ -701,7 +699,6 @@ public final class MessageSender { message: updatedMessage, to: data.destination, interactionId: data.interactionId, - isSyncMessage: isSyncMessage, using: dependencies ) @@ -758,6 +755,7 @@ public final class MessageSender { MessageSender.handleFailedMessageSend( db, message: message, + destination: data.destination, with: .other(error), interactionId: data.interactionId, using: dependencies @@ -829,6 +827,7 @@ public final class MessageSender { MessageSender.handleFailedMessageSend( db, message: message, + destination: data.destination, with: .other(error), interactionId: data.interactionId, using: dependencies @@ -893,6 +892,7 @@ public final class MessageSender { MessageSender.handleFailedMessageSend( db, message: message, + destination: data.destination, with: .other(error), interactionId: data.interactionId, using: dependencies @@ -909,27 +909,33 @@ public final class MessageSender { public static func handleMessageWillSend( _ db: Database, message: Message, - interactionId: Int64?, - isSyncMessage: Bool = false + destination: Message.Destination, + interactionId: Int64? ) { // If the message was a reaction then we don't want to do anything to the original // interaction (which the 'interactionId' is pointing to guard (message as? VisibleMessage)?.reaction == nil else { return } // Mark messages as "sending"/"syncing" if needed (this is for retries) - _ = try? RecipientState - .filter(RecipientState.Columns.interactionId == interactionId) - .filter(isSyncMessage ? - RecipientState.Columns.state == RecipientState.State.failedToSync : - RecipientState.Columns.state == RecipientState.State.failed - ) - .updateAll( - db, - RecipientState.Columns.state.set(to: isSyncMessage ? - RecipientState.State.syncing : - RecipientState.State.sending - ) - ) + switch destination { + case .syncMessage: + _ = try? RecipientState + .filter(RecipientState.Columns.interactionId == interactionId) + .filter(RecipientState.Columns.state == RecipientState.State.failedToSync) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.syncing) + ) + + default: + _ = try? RecipientState + .filter(RecipientState.Columns.interactionId == interactionId) + .filter(RecipientState.Columns.state == RecipientState.State.failed) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.sending) + ) + } } private static func handleSuccessfulMessageSend( @@ -938,7 +944,6 @@ public final class MessageSender { to destination: Message.Destination, interactionId: Int64?, serverTimestampMs: UInt64? = nil, - isSyncMessage: Bool = false, using dependencies: Dependencies ) throws { // If the message was a reaction then we want to update the reaction instead of the original @@ -957,59 +962,61 @@ public final class MessageSender { // Get the visible message if possible if let interaction: Interaction = interaction { // Only store the server hash of a sync message if the message is self send valid - if (message.isSelfSendValid && isSyncMessage || !isSyncMessage) { - try interaction.with( - serverHash: message.serverHash, - // Track the open group server message ID and update server timestamp (use server - // timestamp for open group messages otherwise the quote messages may not be able - // to be found by the timestamp on other devices - timestampMs: (message.openGroupServerMessageId == nil ? - nil : - serverTimestampMs.map { Int64($0) } - ), - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) } - ).update(db) - - if interaction.isExpiringMessage { - // Start disappearing messages job after a message is successfully sent. - // For DAR and DAS outgoing messages, the expiration start time are the - // same as message sentTimestamp. So do this once, DAR and DAS messages - // should all be covered. - dependencies.jobRunner.upsert( - db, - job: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interaction: interaction, - startedAtMs: Double(interaction.timestampMs) + switch (message.isSelfSendValid, destination) { + case (false, .syncMessage): break + case (true, .syncMessage), (_, .contact), (_, .closedGroup), (_, .openGroup), (_, .openGroupInbox): + try interaction.with( + serverHash: message.serverHash, + // Track the open group server message ID and update server timestamp (use server + // timestamp for open group messages otherwise the quote messages may not be able + // to be found by the timestamp on other devices + timestampMs: (message.openGroupServerMessageId == nil ? + nil : + serverTimestampMs.map { Int64($0) } ), - canStartJob: true, - using: dependencies - ) + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) } + ).update(db) - if - isSyncMessage, - let startedAtMs: Double = interaction.expiresStartedAtMs, - let expiresInSeconds: TimeInterval = interaction.expiresInSeconds, - let serverHash: String = message.serverHash - { - let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) - dependencies.jobRunner.add( + if interaction.isExpiringMessage { + // Start disappearing messages job after a message is successfully sent. + // For DAR and DAS outgoing messages, the expiration start time are the + // same as message sentTimestamp. So do this once, DAR and DAS messages + // should all be covered. + dependencies.jobRunner.upsert( db, - job: Job( - variant: .expirationUpdate, - behaviour: .runOnce, - threadId: interaction.threadId, - details: ExpirationUpdateJob.Details( - serverHashes: [serverHash], - expirationTimestampMs: expirationTimestampMs - ) + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: Double(interaction.timestampMs) ), canStartJob: true, using: dependencies ) + + if + case .syncMessage = destination, + let startedAtMs: Double = interaction.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = interaction.expiresInSeconds, + let serverHash: String = message.serverHash + { + let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) + dependencies.jobRunner.add( + db, + job: Job( + variant: .expirationUpdate, + behaviour: .runOnce, + threadId: interaction.threadId, + details: ExpirationUpdateJob.Details( + serverHashes: [serverHash], + expirationTimestampMs: expirationTimestampMs + ) + ), + canStartJob: true, + using: dependencies + ) + } } } - } // Mark the message as sent try interaction.recipientStates @@ -1038,7 +1045,6 @@ public final class MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: isSyncMessage, using: dependencies ) } @@ -1046,9 +1052,9 @@ public final class MessageSender { @discardableResult internal static func handleFailedMessageSend( _ db: Database, message: Message, + destination: Message.Destination?, with error: MessageSenderError, interactionId: Int64?, - isSyncMessage: Bool = false, using dependencies: Dependencies ) -> Error { // If the message was a reaction then we don't want to do anything to the original @@ -1060,18 +1066,27 @@ public final class MessageSender { // Note: The 'db' could be either read-only or writeable so we determine // if a change is required, and if so dispatch to a separate queue for the // actual write - let rowIds: [Int64] = (try? RecipientState - .select(Column.rowID) - .filter(RecipientState.Columns.interactionId == interactionId) - .filter(!isSyncMessage ? - RecipientState.Columns.state == RecipientState.State.sending : ( - RecipientState.Columns.state == RecipientState.State.syncing || - RecipientState.Columns.state == RecipientState.State.sent - ) - ) - .asRequest(of: Int64.self) - .fetchAll(db)) - .defaulting(to: []) + let rowIds: [Int64] = (try? { + switch destination { + case .syncMessage: + return RecipientState + .select(Column.rowID) + .filter(RecipientState.Columns.interactionId == interactionId) + .filter( + RecipientState.Columns.state == RecipientState.State.syncing || + RecipientState.Columns.state == RecipientState.State.sent + ) + + default: + return RecipientState + .select(Column.rowID) + .filter(RecipientState.Columns.interactionId == interactionId) + .filter(RecipientState.Columns.state == RecipientState.State.sending) + } + }() + .asRequest(of: Int64.self) + .fetchAll(db)) + .defaulting(to: []) guard !rowIds.isEmpty else { return error } @@ -1079,15 +1094,25 @@ public final class MessageSender { // issue from occuring in some cases DispatchQueue.global(qos: .background).async { dependencies.storage.write { db in - try RecipientState - .filter(rowIds.contains(Column.rowID)) - .updateAll( - db, - RecipientState.Columns.state.set( - to: (isSyncMessage ? RecipientState.State.failedToSync : RecipientState.State.failed) - ), - RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) - ) + switch destination { + case .syncMessage: + try RecipientState + .filter(rowIds.contains(Column.rowID)) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.failedToSync), + RecipientState.Columns.mostRecentFailureText.set(to: "\(error)") + ) + + default: + try RecipientState + .filter(rowIds.contains(Column.rowID)) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.failed), + RecipientState.Columns.mostRecentFailureText.set(to: "\(error)") + ) + } } } @@ -1116,7 +1141,6 @@ public final class MessageSender { destination: Message.Destination, threadId: String?, interactionId: Int64?, - isAlreadySyncMessage: Bool, using dependencies: Dependencies ) { // Sync the message if it's not a sync message, wasn't already sent to the current user and @@ -1125,7 +1149,6 @@ public final class MessageSender { if case .contact(let publicKey) = destination, - !isAlreadySyncMessage, publicKey != currentUserPublicKey, Message.shouldSync(message: message) { @@ -1139,9 +1162,8 @@ public final class MessageSender { threadId: threadId, interactionId: interactionId, details: MessageSendJob.Details( - destination: .contact(publicKey: currentUserPublicKey), - message: message, - isSyncMessage: true + destination: .syncMessage(originalRecipientPublicKey: publicKey), + message: message ) ), canStartJob: true, diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 45a71652b..c7c6292ee 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -1430,15 +1430,27 @@ public extension Publisher where Output == Set { .mapError { $0 } .flatMap(maxPublishers: maxPublishers) { swarm -> AnyPublisher in var remainingSnodes: Set = swarm + var lastError: Error? return Just(()) .setFailureType(to: Error.self) .tryFlatMap(maxPublishers: maxPublishers) { _ -> AnyPublisher in - let snode: Snode = try remainingSnodes.popRandomElement() ?? { throw SnodeAPIError.generic }() + let snode: Snode = try remainingSnodes.popRandomElement() ?? { + throw SnodeAPIError.ranOutOfRandomSnodes(lastError) + }() return try transform(snode) .eraseToAnyPublisher() } + .mapError { error in + // Prevent nesting the 'ranOutOfRandomSnodes' errors + switch error { + case SnodeAPIError.ranOutOfRandomSnodes: break + default: lastError = error + } + + return error + } .retry(retries) .eraseToAnyPublisher() } diff --git a/SessionSnodeKit/Types/OnionRequestAPIError.swift b/SessionSnodeKit/Types/OnionRequestAPIError.swift index 2d18bc7d2..c6d4b0587 100644 --- a/SessionSnodeKit/Types/OnionRequestAPIError.swift +++ b/SessionSnodeKit/Types/OnionRequestAPIError.swift @@ -5,7 +5,7 @@ import Foundation import SessionUtilitiesKit -public enum OnionRequestAPIError: LocalizedError { +public enum OnionRequestAPIError: Error, CustomStringConvertible { case httpRequestFailedAtDestination(statusCode: UInt, data: Data, destination: OnionRequestAPIDestination) case insufficientSnodes case invalidURL @@ -14,25 +14,25 @@ public enum OnionRequestAPIError: LocalizedError { case unsupportedSnodeVersion(String) case invalidRequestInfo - public var errorDescription: String? { + public var description: String { switch self { case .httpRequestFailedAtDestination(let statusCode, let data, let destination): - if statusCode == 429 { return "Rate limited." } + if statusCode == 429 { return "Rate limited (OnionRequestAPIError.httpRequestFailedAtDestination)." } if let processedResponseBodyData: Data = OnionRequestAPI.process(bencodedData: data)?.body, let errorResponse: String = String(data: processedResponseBodyData, encoding: .utf8) { - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse)." + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse) (OnionRequestAPIError.httpRequestFailedAtDestination)." } if let errorResponse: String = String(data: data, encoding: .utf8) { - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse)." + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse) (OnionRequestAPIError.httpRequestFailedAtDestination)." } - return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode) (OnionRequestAPIError.httpRequestFailedAtDestination)." - case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." - case .invalidURL: return "Invalid URL" - case .missingSnodeVersion: return "Missing Service Node version." - case .snodePublicKeySetMissing: return "Missing Service Node public key set." - case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." - case .invalidRequestInfo: return "Invalid Request Info" + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path (OnionRequestAPIError.insufficientSnodes)." + case .invalidURL: return "Invalid URL (OnionRequestAPIError.invalidURL)." + case .missingSnodeVersion: return "Missing Service Node version (OnionRequestAPIError.missingSnodeVersion)." + case .snodePublicKeySetMissing: return "Missing Service Node public key set (OnionRequestAPIError.snodePublicKeySetMissing)." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version) (OnionRequestAPIError.unsupportedSnodeVersion)." + case .invalidRequestInfo: return "Invalid Request Info (OnionRequestAPIError.invalidRequestInfo)." } } } diff --git a/SessionSnodeKit/Types/SnodeAPIError.swift b/SessionSnodeKit/Types/SnodeAPIError.swift index ef132736b..da3cae60b 100644 --- a/SessionSnodeKit/Types/SnodeAPIError.swift +++ b/SessionSnodeKit/Types/SnodeAPIError.swift @@ -4,7 +4,7 @@ import Foundation -public enum SnodeAPIError: LocalizedError { +public enum SnodeAPIError: Error, CustomStringConvertible { case generic case clockOutOfSync case snodePoolUpdatingFailed @@ -15,29 +15,37 @@ public enum SnodeAPIError: LocalizedError { case invalidIP case emptySnodePool case responseFailedValidation + case ranOutOfRandomSnodes(Error?) // ONS case decryptionFailed case hashingFailed case validationFailed - public var errorDescription: String? { + public var description: String { switch self { - case .generic: return "An error occurred." - case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time." - case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool." - case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network." - case .noKeyPair: return "Missing user key pair." - case .signingFailed: return "Couldn't sign message." - case .signatureVerificationFailed: return "Failed to verify the signature." - case .invalidIP: return "Invalid IP." - case .emptySnodePool: return "Service Node pool is empty." - case .responseFailedValidation: return "Response failed validation." + case .generic: return "An error occurred (SnodeAPIError.generic)." + case .clockOutOfSync: return "Your clock is out of sync with the Service Node network. Please check that your device's clock is set to automatic time (SnodeAPIError.clockOutOfSync)." + case .snodePoolUpdatingFailed: return "Failed to update the Service Node pool (SnodeAPIError.snodePoolUpdatingFailed)." + case .inconsistentSnodePools: return "Received inconsistent Service Node pool information from the Service Node network (SnodeAPIError.inconsistentSnodePools)." + case .noKeyPair: return "Missing user key pair (SnodeAPIError.noKeyPair)." + case .signingFailed: return "Couldn't sign message (SnodeAPIError.signingFailed)." + case .signatureVerificationFailed: return "Failed to verify the signature (SnodeAPIError.signatureVerificationFailed)." + case .invalidIP: return "Invalid IP (SnodeAPIError.invalidIP)." + case .emptySnodePool: return "Service Node pool is empty (SnodeAPIError.emptySnodePool)." + case .responseFailedValidation: return "Response failed validation (SnodeAPIError.responseFailedValidation)." + case .ranOutOfRandomSnodes(let maybeError): + switch maybeError { + case .none: return "Ran out of random snodes (SnodeAPIError.ranOutOfRandomSnodes(nil))." + case .some(let error): + let errorDesc = "\(error)".trimmingCharacters(in: CharacterSet(["."])) + return "Ran out of random snodes (SnodeAPIError.ranOutOfRandomSnodes(\(errorDesc))." + } // ONS - case .decryptionFailed: return "Couldn't decrypt ONS name." - case .hashingFailed: return "Couldn't compute ONS name hash." - case .validationFailed: return "ONS name validation failed." + case .decryptionFailed: return "Couldn't decrypt ONS name (SnodeAPIError.decryptionFailed)." + case .hashingFailed: return "Couldn't compute ONS name hash (SnodeAPIError.hashingFailed)." + case .validationFailed: return "ONS name validation failed (SnodeAPIError.validationFailed)." } } }