diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 1d7ed388db..3532287917 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -789,6 +789,7 @@ FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; FD78E9F02DD6D61200D55B50 /* Data+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754CB2C9BAF37002A2623 /* Data+Image.swift */; }; FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */; }; + FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */; }; FD7F74572BAA9D31006DDFD8 /* _006_DropSnodeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F74562BAA9D31006DDFD8 /* _006_DropSnodeCache.swift */; }; FD7F745B2BAAA35E006DDFD8 /* LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F745A2BAAA35E006DDFD8 /* LibSession.swift */; }; FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7F745E2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift */; }; @@ -835,7 +836,6 @@ FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; FD8A5B342DC1A732004C689B /* _008_ResetUserConfigLastHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */; }; FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */; }; - FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */; }; FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */; }; FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */; }; @@ -879,6 +879,8 @@ FDB11A592DD17D0600BEF49F /* MockLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A552DD17C3000BEF49F /* MockLogger.swift */; }; FDB11A5B2DD1901000BEF49F /* CurrentValueAsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5A2DD1900B00BEF49F /* CurrentValueAsyncStream.swift */; }; FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */; }; + FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */; }; + FDB11A5F2DD5B77800BEF49F /* Message+Origin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */; }; FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB348622BE3774000B716C2 /* BezierPathView.swift */; }; FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */; }; FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */; }; @@ -974,7 +976,6 @@ FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; }; FDE33BBE2D5C3AF100E56F42 /* _023_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */; }; FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; - FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F82AB802BB00450C53 /* Message+Origin.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */; }; FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549C2C9961A4002A2623 /* CommunityPoller.swift */; }; @@ -2011,7 +2012,6 @@ FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; - FD72BD992BDF5EEA00CF6CF6 /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowLevel+Utilities.swift"; sourceTree = ""; }; FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupAPISpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoOpenGroupAPISpec.swift; sourceTree = ""; }; @@ -2027,6 +2027,7 @@ FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _014_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageDataManager.swift; sourceTree = ""; }; + FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader.swift; sourceTree = ""; }; FD7F74562BAA9D31006DDFD8 /* _006_DropSnodeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_DropSnodeCache.swift; sourceTree = ""; }; FD7F745A2BAAA35E006DDFD8 /* LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSession.swift; sourceTree = ""; }; FD7F745E2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TypeConversion+Utilities.swift"; sourceTree = ""; }; @@ -2067,7 +2068,6 @@ FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_ResetUserConfigLastHashes.swift; sourceTree = ""; }; FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCacheSpec.swift; sourceTree = ""; }; - FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+SessionMessagingKit.swift"; sourceTree = ""; }; FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _013_SessionUtilChanges.swift; sourceTree = ""; }; FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigDump.swift; sourceTree = ""; }; @@ -2103,6 +2103,8 @@ FDB11A5A2DD1900B00BEF49F /* CurrentValueAsyncStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValueAsyncStream.swift; sourceTree = ""; }; FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataManager.swift; sourceTree = ""; }; FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDataManager+Singleton.swift"; sourceTree = ""; }; + FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoContent+Utilities.swift"; sourceTree = ""; }; + FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; FDB348622BE3774000B716C2 /* BezierPathView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionUtilitiesKit.h; sourceTree = ""; }; FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; @@ -2194,7 +2196,6 @@ FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = ""; }; FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _023_GroupsExpiredFlag.swift; sourceTree = ""; }; FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = ""; }; - FDE519F82AB802BB00450C53 /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; @@ -2991,10 +2992,10 @@ C300A5C72554B03900555489 /* Control Messages */, C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, - FDE519F82AB802BB00450C53 /* Message+Origin.swift */, - FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */, - FD72BD992BDF5EEA00CF6CF6 /* Message+Origin.swift */, + FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */, + FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, + FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */, ); path = Messages; sourceTree = ""; @@ -3040,6 +3041,7 @@ C32C5B1B256DC160003C73A2 /* Quotes */, C32C5995256DAF85003C73A2 /* Typing Indicators */, FD7728A1284F0DF50018502F /* Message Handling */, + FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, @@ -6253,6 +6255,7 @@ FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, + FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */, FDE754A32C9A8FD1002A2623 /* SwarmPoller.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, @@ -6332,6 +6335,7 @@ C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */, FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */, + FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */, FDE755002C9BB0FA002A2623 /* SessionEnvironment.swift in Sources */, FDB5DADC2A95D840002C8721 /* GroupUpdateMemberChangeMessage.swift in Sources */, FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */, @@ -6374,6 +6378,7 @@ FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */, FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, + FDB11A5F2DD5B77800BEF49F /* Message+Origin.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, FDC383392A93411100FFD6A2 /* Setting+Utilities.swift in Sources */, @@ -6411,7 +6416,6 @@ C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, - FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */, FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */, FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index c10cd1a199..d4bbdafdfd 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -245,10 +245,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { try? webRTCSession .sendPreOffer( - db, message: message, + threadId: thread.id, interactionId: interaction?.id, - in: thread + authMethod: try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies) ) .retry(5) // Start the timeout timer for the call diff --git a/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift b/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift index 853e2ba458..2acb7d03be 100644 --- a/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift +++ b/Session/Calls/WebRTC/WebRTCSession+MessageHandling.swift @@ -15,7 +15,7 @@ extension WebRTCSession { } public func handleRemoteSDP(_ sdp: RTCSessionDescription, from sessionId: String) { - Log.info(.calls, "Received remote SDP: \(sdp.sdp).") + Log.debug(.calls, "Received remote SDP: \(sdp.sdp).") peerConnection?.setRemoteDescription(sdp, completionHandler: { [weak self] error in if let error = error { diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index 390367deae..76a257204d 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -129,23 +129,22 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // MARK: - Signaling public func sendPreOffer( - _ db: Database, message: CallMessage, + threadId: String, interactionId: Int64?, - in thread: SessionThread + authMethod: AuthenticationMethod ) throws -> AnyPublisher { Log.info(.calls, "Sending pre-offer message.") return try MessageSender .preparedSend( - db, message: message, - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + to: .contact(publicKey: threadId), + namespace: .default, interactionId: interactionId, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) .send(using: dependencies) @@ -180,32 +179,31 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: uuid, - kind: .offer, - sdps: [ sdp.sdp ], - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) + .writePublisher { db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + ( + try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) + ) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { authMethod, disappearingMessagesConfiguration in + try MessageSender.preparedSend( + message: CallMessage( + uuid: uuid, + kind: .offer, + sdps: [ sdp.sdp ], + sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: thread.id), + namespace: .default, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ).send(using: dependencies) + } .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -226,14 +224,21 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return dependencies[singleton: .storage] - .readPublisher { db -> SessionThread in - guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { - throw WebRTCSessionError.noThread - } + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: sessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } - return thread + return ( + try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) + ) } - .flatMap { [weak self, dependencies] thread in + .flatMap { [weak self, dependencies] authMethod, disappearingMessagesConfiguration in Future { resolver in self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { @@ -252,40 +257,34 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: uuid, - kind: .answer, - sdps: [ sdp.sdp ] - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: resolver(Result.success(())) - case .failure(let error): resolver(Result.failure(error)) - } - } + Result { + try MessageSender.preparedSend( + message: CallMessage( + uuid: uuid, + kind: .answer, + sdps: [ sdp.sdp ] + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: sessionId), + namespace: .default, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies ) + } + .publisher + .flatMap { $0.send(using: dependencies) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: resolver(Result.success(())) + case .failure(let error): resolver(Result.failure(error)) + } + } + ) } } } @@ -311,17 +310,27 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // Empty the queue self.queuedICECandidates.removeAll() - dependencies[singleton: .storage] - .writePublisher { [dependencies] db -> Network.PreparedRequest in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { - throw WebRTCSessionError.noThread - } + return dependencies[singleton: .storage] + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: contactSessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } + return ( + try Authentication.with(db, swarmPublicKey: contactSessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { [dependencies] authMethod, disappearingMessagesConfiguration in Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.") return try MessageSender .preparedSend( - db, message: CallMessage( uuid: uuid, kind: .iceCandidates( @@ -330,23 +339,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { ), sdps: candidates.map { $0.sdp } ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: contactSessionId), + namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { [dependencies] preparedRequest in - preparedRequest .send(using: dependencies) .retry(5) } @@ -365,37 +366,42 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { public func endCall(with sessionId: String) { return dependencies[singleton: .storage] - .writePublisher { [dependencies, uuid] db -> Network.PreparedRequest in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { - throw WebRTCSessionError.noThread - } + .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread + guard + SessionThread + .filter(id: sessionId) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .isNotEmpty(db) + else { throw WebRTCSessionError.noThread } + return ( + try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), + try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .tryFlatMap { [dependencies, uuid] authMethod, disappearingMessagesConfiguration in Log.info(.calls, "Sending end call message.") return try MessageSender .preparedSend( - db, message: CallMessage( uuid: uuid, kind: .endCall, sdps: [] ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: sessionId), + namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { [dependencies] preparedRequest in - preparedRequest.send(using: dependencies).retry(5) + .send(using: dependencies) + .retry(5) } .sinkUntilComplete( receiveCompletion: { result in diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 742e46ee0b..119dbe4540 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -694,7 +694,7 @@ extension ConversationVC: } // Process any attachments - try Attachment.process( + try AttachmentUploader.process( db, attachments: optimisticData.attachmentData, for: insertedInteraction.id @@ -1519,63 +1519,59 @@ extension ConversationVC: } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { - guard cellViewModel.threadVariant == .community else { return } + guard + cellViewModel.threadVariant == .community, + let roomToken: String = viewModel.threadData.openGroupRoomToken, + let server: String = viewModel.threadData.openGroupServer, + let publicKey: String = viewModel.threadData.openGroupPublicKey, + let capabilities: Set = viewModel.threadData.openGroupCapabilities, + let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + else { return } - viewModel.dependencies[singleton: .storage] - .readPublisher { [dependencies = viewModel.dependencies] db -> (Network.PreparedRequest, OpenGroupAPI.PendingChange) in - guard - let openGroup: OpenGroup = try? OpenGroup - .fetchOne(db, id: cellViewModel.threadId), - let openGroupServerMessageId: Int64 = try? Interaction - .select(.openGroupServerMessageId) - .filter(id: cellViewModel.id) - .asRequest(of: Int64.self) - .fetchOne(db) - else { throw StorageError.objectNotFound } - - let preparedRequest: Network.PreparedRequest = try OpenGroupAPI - .preparedReactionDeleteAll( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - using: dependencies - ) - let pendingChange: OpenGroupAPI.PendingChange = dependencies[singleton: .openGroupManager] - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - type: .removeAll - ) - - return (preparedRequest, pendingChange) - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMap { [dependencies = viewModel.dependencies] preparedRequest, pendingChange in - preparedRequest.send(using: dependencies) - .handleEvents( - receiveOutput: { _, response in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) - } + let pendingChange: OpenGroupAPI.PendingChange = viewModel.dependencies[singleton: .openGroupManager] + .addPendingReaction( + emoji: emoji, + id: openGroupServerMessageId, + in: roomToken, + on: server, + type: .removeAll + ) + + Result { + try OpenGroupAPI.preparedReactionDeleteAll( + emoji: emoji, + id: openGroupServerMessageId, + roomToken: roomToken, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - .eraseToAnyPublisher() - } - .sinkUntilComplete( - receiveCompletion: { [dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync { db in - _ = try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - } + ), + using: viewModel.dependencies ) + } + .publisher + .flatMap { [dependencies = viewModel.dependencies] in $0.send(using: dependencies) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) + .sinkUntilComplete( + receiveCompletion: { [dependencies = viewModel.dependencies] _ in + dependencies[singleton: .storage].writeAsync { db in + _ = try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + } + }, + receiveValue: { [dependencies = viewModel.dependencies] _, response in + dependencies[singleton: .openGroupManager].updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + } + ) } func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { @@ -1587,6 +1583,7 @@ extension ConversationVC: else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) + let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -1644,148 +1641,163 @@ extension ConversationVC: } } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMap { [dependencies = viewModel.dependencies] pendingChange -> AnyPublisher, Error> in - dependencies[singleton: .storage].writePublisher { [weak self] db -> Network.PreparedRequest in - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - _ = try SessionThread - .filter(id: cellViewModel.threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.shouldBeVisible.set(to: true), - using: dependencies - ) - } - - let pendingReaction: Reaction? = { - guard !remove else { - return try? Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) - .filter(Reaction.Columns.emoji == emoji) - .fetchOne(db) - } - - let sortId: Int64 = Reaction.getSortId( + .flatMapStorageWritePublisher(using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] db, pendingChange -> (OpenGroupAPI.PendingChange?, Reaction?, Message.Destination, AuthenticationMethod) in + // Update the thread to be visible (if it isn't already) + if self?.viewModel.threadData.threadShouldBeVisible == false { + _ = try SessionThread + .filter(id: cellViewModel.threadId) + .updateAllAndConfig( db, - interactionId: cellViewModel.id, - emoji: emoji - ) - - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestampMs, - authorId: cellViewModel.currentUserSessionId, - emoji: emoji, - count: 1, - sortId: sortId + SessionThread.Columns.shouldBeVisible.set(to: true), + using: dependencies ) - }() - - // Update the database - if remove { - try Reaction + } + + let pendingReaction: Reaction? = { + guard !remove else { + return try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) + // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - else { - try pendingReaction?.insert(db) - - // Add it to the recent list - Emoji.addRecent(db, emoji: emoji) + .fetchOne(db) } - switch threadVariant { - case .community: - guard - let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupRoom: String = openGroupRoom, - let pendingChange: OpenGroupAPI.PendingChange = pendingChange, - dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) - else { throw MessageSenderError.invalidMessage } - - let preparedRequest: Network.PreparedRequest = try { - guard !remove else { - return try OpenGroupAPI - .preparedReactionDelete( - db, - emoji: emoji, - id: serverMessageId, - in: openGroupRoom, - on: openGroupServer, - using: dependencies - ) - .map { _, response in response.seqNo } - } - + let sortId: Int64 = Reaction.getSortId( + db, + interactionId: cellViewModel.id, + emoji: emoji + ) + + return Reaction( + interactionId: cellViewModel.id, + serverHash: nil, + timestampMs: sentTimestampMs, + authorId: cellViewModel.currentUserSessionId, + emoji: emoji, + count: 1, + sortId: sortId + ) + }() + + // Update the database + if remove { + try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable + .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + } + else { + try pendingReaction?.insert(db) + + // Add it to the recent list + Emoji.addRecent(db, emoji: emoji) + } + + switch threadVariant { + case .community: + guard + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) + else { throw MessageSenderError.invalidMessage } + + default: break + } + + return ( + pendingChange, + pendingReaction, + try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), + try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) + ) + } + .tryFlatMap { [dependencies = viewModel.dependencies] pendingChange, pendingReaction, destination, authMethod in + switch threadVariant { + case .community: + guard + let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupRoom: String = openGroupRoom, + let pendingChange: OpenGroupAPI.PendingChange = pendingChange + else { throw MessageSenderError.invalidMessage } + + let preparedRequest: Network.PreparedRequest = try { + guard !remove else { return try OpenGroupAPI - .preparedReactionAdd( - db, + .preparedReactionDelete( emoji: emoji, id: serverMessageId, - in: openGroupRoom, - on: openGroupServer, + roomToken: openGroupRoom, + authMethod: authMethod, using: dependencies ) .map { _, response in response.seqNo } - }() + } - return preparedRequest - .handleEvents( - receiveOutput: { _, seqNo in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: seqNo - ) - }, - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) - - self?.handleReactionSentFailure(pendingReaction, remove: remove) - } - } + return try OpenGroupAPI + .preparedReactionAdd( + emoji: emoji, + id: serverMessageId, + roomToken: openGroupRoom, + authMethod: authMethod, + using: dependencies ) - .map { _, _ in () } - - default: - return try MessageSender.preparedSend( - db, - message: VisibleMessage( - sentTimestampMs: UInt64(sentTimestampMs), - text: nil, - reaction: VisibleMessage.VMReaction( - timestamp: UInt64(cellViewModel.timestampMs), - publicKey: { - guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserSessionId - } - - return cellViewModel.authorId - }(), - emoji: emoji, - kind: (remove ? .remove : .react) + .map { _, response in response.seqNo } + }() + + return preparedRequest + .handleEvents( + receiveOutput: { _, seqNo in + dependencies[singleton: .openGroupManager].updatePendingChange( + pendingChange, + seqNo: seqNo ) - ), - to: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant), - namespace: try Message.Destination - .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) - .defaultNamespace, - interactionId: cellViewModel.id, - fileIds: [], - using: dependencies + }, + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) + + self?.handleReactionSentFailure(pendingReaction, remove: remove) + } + } ) - } + .map { _, _ in () } + .send(using: dependencies) + + default: + return try MessageSender.preparedSend( + message: VisibleMessage( + sentTimestampMs: UInt64(sentTimestampMs), + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: UInt64(cellViewModel.timestampMs), + publicKey: { + guard cellViewModel.variant == .standardIncoming else { + return cellViewModel.currentUserSessionId + } + + return cellViewModel.authorId + }(), + emoji: emoji, + kind: (remove ? .remove : .react) + ) + ), + to: destination, + namespace: .default, + interactionId: cellViewModel.id, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + .map { _, _ in () } + .send(using: dependencies) } } - .flatMap { [dependencies = viewModel.dependencies] request in request.send(using: dependencies) } .sinkUntilComplete() } @@ -2270,46 +2282,61 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } - - return try OpenGroupAPI - .preparedUserBan( - db, - sessionId: cellViewModel.authorId, - from: [openGroup.roomToken], - on: openGroup.server, - using: dependencies + onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + Result { + guard + cellViewModel.threadVariant == .community, + let roomToken: String = threadData.openGroupRoomToken, + let server: String = threadData.openGroupServer, + let publicKey: String = threadData.openGroupPublicKey, + let capabilities: Set = threadData.openGroupCapabilities, + let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + else { throw CryptoError.invalidAuthentication } + + return ( + roomToken, + Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - DispatchQueue.main.async { [weak self] in - switch result { - case .finished: - self?.viewModel.showToast( - text: "banUserBanned".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - case .failure: - self?.viewModel.showToast( - text: "banErrorFailed".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - } - completion?() + ) + ) + } + .publisher + .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + try OpenGroupAPI.preparedUserBan( + sessionId: cellViewModel.authorId, + from: [roomToken], + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: + self?.viewModel.showToast( + text: "banUserBanned".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) + case .failure: + self?.viewModel.showToast( + text: "banErrorFailed".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) } + completion?() } - ) + } + ) self?.becomeFirstResponder() }, @@ -2334,46 +2361,61 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage] - .readPublisher { db in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } + onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + Result { + guard + cellViewModel.threadVariant == .community, + let roomToken: String = threadData.openGroupRoomToken, + let server: String = threadData.openGroupServer, + let publicKey: String = threadData.openGroupPublicKey, + let capabilities: Set = threadData.openGroupCapabilities, + let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + else { throw CryptoError.invalidAuthentication } - return try OpenGroupAPI - .preparedUserBanAndDeleteAllMessages( - db, - sessionId: cellViewModel.authorId, - in: openGroup.roomToken, - on: openGroup.server, - using: dependencies + return ( + roomToken, + Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: capabilities ) - } - .flatMap { $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - DispatchQueue.main.async { [weak self] in - switch result { - case .finished: - self?.viewModel.showToast( - text: "banUserBanned".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - case .failure: - self?.viewModel.showToast( - text: "banErrorFailed".localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) - } - completion?() + ) + ) + } + .publisher + .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + sessionId: cellViewModel.authorId, + roomToken: roomToken, + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: + self?.viewModel.showToast( + text: "banUserBanned".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) + case .failure: + self?.viewModel.showToast( + text: "banErrorFailed".localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) } + completion?() } - ) + } + ) self?.becomeFirstResponder() }, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index c2671ea48e..e8027852f1 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -270,6 +270,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies ).populatingPostQueryData( + recentReactionEmoji: nil, + openGroupCapabilities: nil, currentUserSessionIds: ( initialData?.currentUserSessionIds ?? [dependencies[cache: .general].sessionId.hexString] @@ -349,33 +351,42 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationQuery(threadId: threadId, userSessionId: userSessionId) .fetchOne(db) + let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? + nil : + try Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == threadViewModel?.openGroupServer?.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db) + ) - return threadViewModel - .map { $0.with(recentReactionEmoji: recentReactionEmoji) } - .map { viewModel -> SessionThreadViewModel in - let wasKickedFromGroup: Bool = ( - viewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) - } - ) - let groupIsDestroyed: Bool = ( - viewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) - } - ) - - return viewModel.populatingPostQueryData( - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies) - ) - } + return threadViewModel.map { viewModel -> SessionThreadViewModel in + let wasKickedFromGroup: Bool = ( + viewModel.threadVariant == .group && + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } + ) + let groupIsDestroyed: Bool = ( + viewModel.threadVariant == .group && + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } + ) + + return viewModel.populatingPostQueryData( + recentReactionEmoji: recentReactionEmoji, + openGroupCapabilities: openGroupCapabilities, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), + wasKickedFromGroup: wasKickedFromGroup, + groupIsDestroyed: groupIsDestroyed, + threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies) + ) + } } .removeDuplicates() .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) @@ -731,7 +742,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) let optimisticAttachments: [Attachment]? = attachments - .map { Attachment.prepare(attachments: $0, using: dependencies) } + .map { AttachmentUploader.prepare(attachments: $0, using: dependencies) } let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in try? LinkPreview.generateAttachmentIfPossible( imageData: draft.jpegImageData, diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 4a396c328d..f0bc18de3b 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -364,6 +364,8 @@ public class HomeViewModel: NavigatableStateHolder { } .map { viewModel -> SessionThreadViewModel in viewModel.populatingPostQueryData( + recentReactionEmoji: nil, + openGroupCapabilities: nil, currentUserSessionIds: (groupedOldData[viewModel.threadId]? .first? .currentUserSessionIds) diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 0a016bc9dd..e57b2f6ac8 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -146,6 +146,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .map { [dependencies] viewModel -> SessionCell.Info in SessionCell.Info( id: viewModel.populatingPostQueryData( + recentReactionEmoji: nil, + openGroupCapabilities: nil, currentUserSessionIds: (groupedOldData[viewModel.threadId]? .first? .id diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift index 84dc938ae3..d69ef90d5d 100644 --- a/Session/Meta/MainAppContext.swift +++ b/Session/Meta/MainAppContext.swift @@ -130,7 +130,7 @@ final class MainAppContext: AppContext { // MARK: - AppContext Functions - func setMainWindow(_ mainWindow: UIWindow) { + @MainActor func setMainWindow(_ mainWindow: UIWindow) { self.mainWindow = mainWindow // Store in SessionUIKit to avoid needing the SessionUtilitiesKit dependency diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index d4be294dbe..33719bce92 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -67,7 +67,7 @@ public class SessionApp: SessionAppType { self.homeViewController = homeViewController } - public func presentConversationCreatingIfNeeded( + @MainActor public func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action = .none, @@ -79,23 +79,8 @@ public class SessionApp: SessionAppType { return } - let threadInfo: (threadExists: Bool, isMessageRequest: Bool)? = dependencies[singleton: .storage].read { [dependencies] db in - let isMessageRequest: Bool = { - switch variant { - case .contact, .group: - return SessionThread - .isMessageRequest( - db, - threadId: threadId, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - default: return false - } - }() - - return (SessionThread.filter(id: threadId).isNotEmpty(db), isMessageRequest) + let threadExists: Bool? = dependencies[singleton: .storage].read { db in + SessionThread.filter(id: threadId).isNotEmpty(db) } /// The thread should generally exist at the time of calling this method, but on the off chance it doesn't then we need to @@ -104,12 +89,14 @@ public class SessionApp: SessionAppType { creatingThreadIfNeededThenRunOnMain( threadId: threadId, variant: variant, - threadExists: (threadInfo?.threadExists == true), - onComplete: { [weak self] in + threadExists: (threadExists == true), + onComplete: { [weak self, dependencies] in self?.showConversation( threadId: threadId, threadVariant: variant, - isMessageRequest: (threadInfo?.isMessageRequest == true), + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: variant) + }, action: action, dismissing: presentingViewController, homeViewController: homeViewController, @@ -182,45 +169,35 @@ public class SessionApp: SessionAppType { // MARK: - Internal Functions - private func creatingThreadIfNeededThenRunOnMain( + @MainActor private func creatingThreadIfNeededThenRunOnMain( threadId: String, variant: SessionThread.Variant, threadExists: Bool, onComplete: @escaping () -> Void ) { guard !threadExists else { - switch Thread.isMainThread { - case true: return onComplete() - case false: return DispatchQueue.main.async(using: dependencies) { onComplete() } - } - } - guard !Thread.isMainThread else { - return DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { [weak self] in - self?.creatingThreadIfNeededThenRunOnMain( - threadId: threadId, - variant: variant, - threadExists: threadExists, - onComplete: onComplete - ) - } + return onComplete() } - dependencies[singleton: .storage].write { [dependencies] db in - try SessionThread.upsert( - db, - id: threadId, - variant: variant, - values: SessionThread.TargetValues( - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies + Task { [storage = dependencies[singleton: .storage], dependencies] in + storage.writeAsync( + updates: { db in + try SessionThread.upsert( + db, + id: threadId, + variant: variant, + values: SessionThread.TargetValues( + shouldBeVisible: .useLibSession, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies + ) + }, + completion: { _ in + Task { @MainActor in onComplete() } + } ) } - - DispatchQueue.main.async(using: dependencies) { - onComplete() - } } private func showConversation( @@ -258,7 +235,7 @@ public class SessionApp: SessionAppType { public protocol SessionAppType { func setHomeViewController(_ homeViewController: HomeVC) func showHomeView() - func presentConversationCreatingIfNeeded( + @MainActor func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action, diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index 82d3d55790..12249e7fef 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -30,11 +30,10 @@ public class NotificationActionHandler { // MARK: - Handling - func handleNotificationResponse( + @MainActor func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void ) { - Log.assertOnMainThread() handleNotificationResponse(response) .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) @@ -51,8 +50,7 @@ public class NotificationActionHandler { ) } - func handleNotificationResponse(_ response: UNNotificationResponse) -> AnyPublisher { - Log.assertOnMainThread() + @MainActor func handleNotificationResponse(_ response: UNNotificationResponse) -> AnyPublisher { assert(dependencies[singleton: .appReadiness].isAppReady) let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo @@ -141,18 +139,21 @@ public class NotificationActionHandler { replyText: String, applicationState: UIApplication.State ) -> AnyPublisher { - guard let threadId = userInfo[NotificationUserInfoKey.threadId] as? String else { - return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) - .eraseToAnyPublisher() - } - - guard let thread: SessionThread = dependencies[singleton: .storage].read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { - return Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) + guard + let threadId = userInfo[NotificationUserInfoKey.threadId] as? String, + let threadVariantRaw = userInfo[NotificationUserInfoKey.threadVariantRaw] as? Int, + let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) + else { + return Fail(error: NotificationError.failDebug("thread information was unexpectedly nil")) .eraseToAnyPublisher() } return dependencies[singleton: .storage] - .writePublisher { [dependencies] db -> Network.PreparedRequest in + .writePublisher { [dependencies] db -> (Message, Message.Destination, Int64?, AuthenticationMethod) in + guard (try? SessionThread.exists(db, id: threadId)) == true else { + throw NotificationError.failDebug("unable to find thread with id: \(threadId)") + } + let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration .filter(id: threadId) @@ -160,7 +161,7 @@ public class NotificationActionHandler { .fetchOne(db) let interaction: Interaction = try Interaction( threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, authorId: dependencies[cache: .general].sessionId.hexString, variant: .standardOutgoing, body: replyText, @@ -177,47 +178,62 @@ public class NotificationActionHandler { db, interactionId: interaction.id, threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, includingOlder: true, trySendReadReceipt: try SessionThread.canSendReadReceipt( db, threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, using: dependencies ), using: dependencies ) - return try MessageSender.preparedSend( + let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) + let destination: Message.Destination = try Message.Destination.from( + db, + threadId: threadId, + threadVariant: threadVariant + ) + let authMethod: AuthenticationMethod = try Authentication.with( db, - interaction: interaction, - fileIds: [], threadId: threadId, - threadVariant: thread.variant, + threadVariant: threadVariant, using: dependencies ) + + return (visibleMessage, destination, interaction.id, authMethod) + } + .tryFlatMap { [dependencies] message, destination, interactionId, authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( + message: message, + to: destination, + namespace: destination.defaultNamespace, + interactionId: interactionId, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ).send(using: dependencies) } - .flatMap { [dependencies] request in request.send(using: dependencies) } .map { _ in () } .handleEvents( receiveCompletion: { [dependencies] result in switch result { case .finished: break case .failure: - dependencies[singleton: .storage].read { db in - dependencies[singleton: .notificationsManager].notifyForFailedSend( - db, - in: thread, - applicationState: applicationState - ) - } + dependencies[singleton: .notificationsManager].notifyForFailedSend( + threadId: threadId, + threadVariant: threadVariant, + applicationState: applicationState + ) } } ) .eraseToAnyPublisher() } - func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { + @MainActor func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { guard let threadId = userInfo[NotificationUserInfoKey.threadId] as? String, let threadVariantRaw = userInfo[NotificationUserInfoKey.threadVariantRaw] as? Int, diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index 0ed15bb551..45919e1894 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -79,26 +79,26 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, // MARK: - Presentation public func notifyForFailedSend( - _ db: Database, - in thread: SessionThread, + threadId: String, + threadVariant: SessionThread.Variant, applicationState: UIApplication.State ) { let notificationSettings: Preferences.NotificationSettings = dependencies.mutate(cache: .libSession) { cache in cache.notificationSettings( - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, openGroupUrlInfo: nil /// Communities current don't support PNs ) } var content: NotificationContent = NotificationContent( - threadId: thread.id, - threadVariant: thread.variant, - identifier: thread.id, + threadId: threadId, + threadVariant: threadVariant, + identifier: threadId, category: .errorMessage, body: "messageErrorDelivery".localized(), sound: notificationSettings.sound, - userInfo: notificationUserInfo(threadId: thread.id, threadVariant: thread.variant), + userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), applicationState: applicationState ) @@ -106,24 +106,34 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, switch notificationSettings.previewType { case .noNameNoPreview: content = content.with(title: Constants.app_name) case .nameNoPreview, .nameAndPreview: + typealias ThreadInfo = (profile: Profile?, openGroupName: String?, openGroupUrlInfo: LibSession.OpenGroupUrlInfo?) + let threadInfo: ThreadInfo? = dependencies[singleton: .storage].read { db in + return ( + (threadVariant != .contact ? nil : + try? Profile.fetchOne(db, id: threadId) + ), + (threadVariant != .community ? nil : + try? OpenGroup + .select(.name) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + ), + (threadVariant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) + ) + ) + } + content = content.with( title: dependencies.mutate(cache: .libSession) { cache in cache.conversationDisplayName( - threadId: thread.id, - threadVariant: thread.variant, - contactProfile: (thread.variant != .contact ? nil : - try? Profile.fetchOne(db, id: thread.id) - ), + threadId: threadId, + threadVariant: threadVariant, + contactProfile: threadInfo?.profile, visibleMessage: nil, /// This notification is unrelated to the received message - openGroupName: (thread.variant != .community ? nil : - try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db) - ), - openGroupUrlInfo: (thread.variant != .community ? nil : - try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: thread.id) - ) + openGroupName: threadInfo?.openGroupName, + openGroupUrlInfo: threadInfo?.openGroupUrlInfo ) } ) @@ -154,7 +164,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, let identifier: String = "sessionNetworkPageLocalNotifcation_\(UUID().uuidString)" // stringlint:disable // Schedule the notification after 1 hour - var content: NotificationContent = NotificationContent( + let content: NotificationContent = NotificationContent( threadId: nil, threadVariant: nil, identifier: identifier, diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index c19519fcb1..5065281d4b 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -140,6 +140,8 @@ extension Onboarding { return .completed }() + self.seed = Data() /// Overwritten below + self.useAPNS = false /// Overwritten below /// Update the cached values depending on the `initialState` switch state { @@ -158,21 +160,16 @@ extension Onboarding { /// Seed or identity generation failed so leave the `Onboarding.Cache` in an invalid state for the UI to /// recover somehow self.state = .noUserFailedIdentity - self.seed = Data() - self.ed25519KeyPair = .empty - self.x25519KeyPair = .empty - self.userSessionId = .invalid - self.useAPNS = false return } /// The identity data was successfully generated so store it for the onboarding process - self.state = .noUser self.seed = finalSeedData - self.useAPNS = false + self.ed25519KeyPair = identity.ed25519KeyPair + self.x25519KeyPair = identity.x25519KeyPair + self.userSessionId = SessionId(.standard, publicKey: identity.x25519KeyPair.publicKey) case .missingName, .completed: - self.seed = Data() self.useAPNS = dependencies[defaults: .standard, key: .isUsingFullAPNs] /// If we are already in a completed state then updated the completion subject accordingly @@ -358,13 +355,13 @@ extension Onboarding { ) /// Load the initial `libSession` state (won't have been created on launch due to lack of ed25519 key) - dependencies.mutate(cache: .libSession) { - $0.loadState(db) + dependencies.mutate(cache: .libSession) { cache in + cache.loadState(db) /// If we have a `userProfileConfigMessage` then we should try to handle it here as if we don't then /// we won't even process it (because the hash may be deduped via another process) if let userProfileConfigMessage: ProcessedMessage = userProfileConfigMessage { - try? $0.handleConfigMessages( + try? cache.handleConfigMessages( db, swarmPublicKey: userSessionId.hexString, messages: ConfigMessageReceiveJob @@ -373,7 +370,10 @@ extension Onboarding { ) } - try? $0.updateProfile(displayName: displayName) + /// Update the `displayName` and trigger a dump/push of the config + try? cache.performAndPushChange(db, for: .userProfile) { + try? cache.updateProfile(displayName: displayName) + } } /// Clear the `lastNameUpdate` timestamp and forcibly set the `displayName` provided during the onboarding diff --git a/Session/Settings/AppearanceViewModel.swift b/Session/Settings/AppearanceViewModel.swift index d6eaf99fce..955f8c192a 100644 --- a/Session/Settings/AppearanceViewModel.swift +++ b/Session/Settings/AppearanceViewModel.swift @@ -86,7 +86,7 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ isSelected: (state.theme == theme) ), onTap: { - ThemeManager.updateThemeState(theme: theme) + Task { @MainActor in ThemeManager.updateThemeState(theme: theme) } } ) } @@ -111,7 +111,7 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ info: PrimaryColorSelectionView.Info( primaryColor: state.primaryColor, onChange: { color in - ThemeManager.updateThemeState(primaryColor: color) + Task { @MainActor in ThemeManager.updateThemeState(primaryColor: color) } } ) ), @@ -136,9 +136,11 @@ class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, Observ oldValue: ThemeManager.matchSystemNightModeSetting ), onTap: { - ThemeManager.updateThemeState( - matchSystemNightModeSetting: !state.authDarkModeEnabled - ) + Task { @MainActor in + ThemeManager.updateThemeState( + matchSystemNightModeSetting: !state.authDarkModeEnabled + ) + } } ) ] diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index b30cc17e66..03f39b71d0 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -178,16 +178,11 @@ final class NukeDataModal: Modal { ModalActivityIndicatorViewController .present(fromViewController: presentedViewController, canCancel: false) { [weak self, dependencies] _ in dependencies[singleton: .storage] - .readPublisher { db -> PreparedClearRequests in + .readPublisher { db -> (AuthenticationMethod, [AuthenticationMethod]) in ( - try SnodeAPI.preparedDeleteAllMessages( - namespace: .all, - requestAndPathBuildTimeout: Network.defaultTimeout, - authMethod: try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), + try Authentication.with( + db, + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), try OpenGroup @@ -196,28 +191,40 @@ final class NukeDataModal: Modal { .distinct() .asRequest(of: String.self) .fetchSet(db) - .map { server in - try OpenGroupAPI - .preparedClearInbox( - db, - on: server, - requestAndPathBuildTimeout: Network.defaultTimeout, - using: dependencies - ) - .map { _, _ in server } - } + .map { try Authentication.with(db, server: $0, using: dependencies) } ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .flatMap { preparedRequests -> AnyPublisher<(Network.PreparedRequest<[String: Bool]>, [String]), Error> in + .tryFlatMap { (userAuth: AuthenticationMethod, communityAuth: [AuthenticationMethod]) -> AnyPublisher<(AuthenticationMethod, [String]), Error> in Publishers - .MergeMany(preparedRequests.inboxRequestInfo.map { $0.send(using: dependencies) }) + .MergeMany( + try communityAuth.compactMap { authMethod in + switch authMethod.info { + case .community(let server, _, _, _, _): + return try OpenGroupAPI.preparedClearInbox( + requestAndPathBuildTimeout: Network.defaultTimeout, + authMethod: authMethod, + using: dependencies + ) + .map { _, _ in server } + .send(using: dependencies) + + default: return nil + } + } + ) .collect() - .map { response in (preparedRequests.deleteAll, response.map { $0.1 }) } + .map { response in (userAuth, response.map { $0.1 }) } .eraseToAnyPublisher() } - .flatMap { preparedDeleteAllRequest, clearedServers in - preparedDeleteAllRequest + .tryFlatMap { authMethod, clearedServers in + try SnodeAPI + .preparedDeleteAllMessages( + namespace: .all, + requestAndPathBuildTimeout: Network.defaultTimeout, + authMethod: authMethod, + using: dependencies + ) .send(using: dependencies) .map { _, data in clearedServers.reduce(into: data) { result, next in result[next] = true } diff --git a/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift index 9e6e66d9af..bb26911130 100644 --- a/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift +++ b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift @@ -205,7 +205,7 @@ enum _026_MessageDeduplicationTable: Migration { /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } /// If this record would have already expired then there is no need to insert a record for it guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } @@ -278,7 +278,7 @@ enum _026_MessageDeduplicationTable: Migration { /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } /// If this record would have already expired then there is no need to insert a record for it guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index a5f3472f84..6c4faaf831 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -1115,262 +1115,3 @@ extension Attachment { } } } - -// MARK: - Upload - -extension Attachment { - private enum Destination { - case fileServer - case community(OpenGroup) - - var shouldEncrypt: Bool { - switch self { - case .fileServer: return true - case .community: return false - } - } - } - - public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] { - return attachments.compactMap { signalAttachment in - Attachment( - variant: (signalAttachment.isVoiceMessage ? - .voiceMessage : - .standard - ), - contentType: signalAttachment.mimeType, - dataSource: signalAttachment.dataSource, - sourceFilename: signalAttachment.sourceFilename, - caption: signalAttachment.captionText, - using: dependencies - ) - } - } - - public static func process( - _ db: Database, - attachments: [Attachment]?, - for interactionId: Int64? - ) throws { - guard - let attachments: [Attachment] = attachments, - let interactionId: Int64 = interactionId - else { return } - - try attachments - .enumerated() - .forEach { index, attachment in - let interactionAttachment: InteractionAttachment = InteractionAttachment( - albumIndex: index, - interactionId: interactionId, - attachmentId: attachment.id - ) - - try attachment.insert(db) - try interactionAttachment.insert(db) - } - } - - public func preparedUpload( - _ db: Database, - threadId: String, - logCategory cat: Log.Category?, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - typealias UploadInfo = ( - attachment: Attachment, - preparedRequest: Network.PreparedRequest, - encryptionKey: Data?, - digest: Data? - ) - - // Retrieve the correct destination for the given thread - let destination: Destination = (try? OpenGroup.fetchOne(db, id: threadId)) - .map { .community($0) } - .defaulting(to: .fileServer) - let uploadInfo: UploadInfo = try { - let endpoint: (any EndpointType) = { - switch destination { - case .fileServer: return Network.FileServer.Endpoint.file - case .community(let openGroup): return OpenGroupAPI.Endpoint.roomFile(openGroup.roomToken) - } - }() - - // This can occur if an AttachmentUploadJob was explicitly created for a message - // dependant on the attachment being uploaded (in this case the attachment has - // already been uploaded so just succeed) - if state == .uploaded, let fileId: String = Attachment.fileId(for: downloadUrl) { - return ( - self, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), - endpoint: endpoint, - using: dependencies - ), - self.encryptionKey, - self.digest - ) - } - - // If the attachment is a downloaded attachment, check if it came from - // the server and if so just succeed immediately (no use re-uploading - // an attachment that is already present on the server) - or if we want - // it to be encrypted and it's not then encrypt it - // - // Note: The most common cases for this will be for LinkPreviews or Quotes - if - state == .downloaded, - serverId != nil, - let fileId: String = Attachment.fileId(for: downloadUrl), - ( - !destination.shouldEncrypt || ( - encryptionKey != nil && - digest != nil - ) - ) - { - return ( - self, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), - endpoint: endpoint, - using: dependencies - ), - self.encryptionKey, - self.digest - ) - } - - // Get the raw attachment data - guard let rawData: Data = try? readDataFromFile(using: dependencies) else { - Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") - throw AttachmentError.noAttachment - } - - // Encrypt the attachment if needed - var finalData: Data = rawData - var encryptionKey: Data? - var digest: Data? - - typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) - if destination.shouldEncrypt { - guard - let result: EncryptionData = dependencies[singleton: .crypto].generate( - .encryptAttachment(plaintext: rawData) - ) - else { - Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") - throw AttachmentError.encryptionFailed - } - - finalData = result.ciphertext - encryptionKey = result.encryptionKey - digest = result.digest - } - - // Ensure the file size is smaller than our upload limit - Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.") - guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } - - // Generate the request - switch destination { - case .fileServer: - return ( - self, - try Network.preparedUpload(data: finalData, using: dependencies), - encryptionKey, - digest - ) - - case .community(let openGroup): - return ( - self, - try OpenGroupAPI.preparedUpload( - db, - data: finalData, - to: openGroup.roomToken, - on: openGroup.server, - using: dependencies - ), - encryptionKey, - digest - ) - } - }() - - return uploadInfo.preparedRequest - .handleEvents( - receiveSubscription: { - // If we have a `cachedResponse` (ie. already uploaded) then don't change - // the attachment state to uploading as it's already been done - guard uploadInfo.preparedRequest.cachedResponse == nil else { return } - - // Update the attachment to the 'uploading' state - dependencies[singleton: .storage].write { db in - _ = try? Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - } - }, - receiveOutput: { _, response in - /// Save the final upload info - /// - /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is - /// updated correctly - let updatedAttachment: Attachment = uploadInfo.attachment - .with( - serverId: response.id, - state: .uploaded, - creationTimestamp: ( - uploadInfo.attachment.creationTimestamp ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - downloadUrl: { - switch (uploadInfo.attachment.downloadUrl, destination) { - case (.some(let downloadUrl), _): return downloadUrl - case (.none, .fileServer): - return Network.FileServer.downloadUrlString(for: response.id) - - case (.none, .community(let openGroup)): - return OpenGroupAPI.downloadUrlString( - for: response.id, - server: openGroup.server, - roomToken: openGroup.roomToken - ) - } - }(), - encryptionKey: uploadInfo.encryptionKey, - digest: uploadInfo.digest, - using: dependencies - ) - - // Ensure there were changes before triggering a db write to avoid unneeded - // write queue use and UI updates - guard updatedAttachment != uploadInfo.attachment else { return } - - dependencies[singleton: .storage].write { db in - try updatedAttachment.upserted(db) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .storage].write { db in - try Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - } - } - }, - receiveCancel: { - dependencies[singleton: .storage].write { db in - try Attachment - .filter(id: uploadInfo.attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - } - } - ) - .map { _, response in response.id } - } -} diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift index e967a509c1..72b3f9f614 100644 --- a/SessionMessagingKit/Database/Models/MessageDeduplication.swift +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -40,24 +40,36 @@ public extension MessageDeduplication { uniqueIdentifier: String?, legacyIdentifier: String? = nil, message: Message?, - serverExpirationTimestamp: Int64?, + serverExpirationTimestamp: TimeInterval?, + ignoreDedupeFiles: Bool, using dependencies: Dependencies ) throws { /// If we don't have a `uniqueIdentifier` then we can't dedupe the message guard let uniqueIdentifier: String = uniqueIdentifier else { return } - /// Ensure this isn't a duplicate message received as a PN first - try ensureMessageIsNotADuplicate( - threadId: threadId, - uniqueIdentifier: uniqueIdentifier, - legacyIdentifier: legacyIdentifier, - using: dependencies - ) + /// If we aren't ignoring dedupe files then check to ensure they don't already exist (ie. a message was received as a push + /// notification but doesn't yet exist in the database) + if !ignoreDedupeFiles { + /// Ensure this isn't a duplicate message received as a PN first + try ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + legacyIdentifier: legacyIdentifier, + using: dependencies + ) + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the same call + try ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: message as? CallMessage, + using: dependencies + ) + } /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = serverExpirationTimestamp - .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + .map { Int64($0) + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) @@ -95,6 +107,16 @@ public extension MessageDeduplication { using: dependencies ) + /// Insert & create special call-specific dedupe records + try insertCallDedupeRecordsIfNeeded( + db, + threadId: threadId, + callMessage: message as? CallMessage, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread, + using: dependencies + ) + /// Create a legacy dedupe record try createLegacyDeduplicationRecord( db, @@ -194,6 +216,78 @@ public extension MessageDeduplication { throw MessageReceiverError.duplicateMessage } } +} + +// MARK: - CallMessage Convenience + +public extension MessageDeduplication { + static func insertCallDedupeRecordsIfNeeded( + _ db: Database, + threadId: String, + callMessage: CallMessage?, + expirationTimestampSeconds: Int64?, + shouldDeleteWhenDeletingThread: Bool, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + switch (callMessage.kind, callMessage.state) { + /// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call + case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone): + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: callMessage.uuid, + expirationTimestampSeconds: expirationTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// We only want to handle a single `preOffer` so add a custom record for that + case (.preOffer, _): + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + expirationTimestampSeconds: expirationTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going) + default: break + } + + /// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe call messages + try createCallDedupeFilesIfNeeded( + threadId: threadId, + callMessage: callMessage, + using: dependencies + ) + } + + static func createCallDedupeFilesIfNeeded( + threadId: String, + callMessage: CallMessage?, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + switch (callMessage.kind, callMessage.state) { + /// If the call was ended, was missed or had a permission issue then reject all subsequent messages associated with the call + case (.endCall, _), (_, .missed), (_, .permissionDenied), (_, .permissionDeniedMicrophone): + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.uuid + ) + + /// We only want to handle a single `preOffer` so add a custom record for that + case (.preOffer, _): + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier + ) + + /// For any other combinations we don't want to deduplicate messages (as they are needed to keep the call going) + default: break + } + } static func ensureCallMessageIsNotADuplicate( threadId: String, @@ -203,6 +297,16 @@ public extension MessageDeduplication { guard let callMessage: CallMessage = callMessage else { return } do { + /// We only want to handle the `preOffer` message once + if callMessage.kind == .preOffer { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + } + + /// If a call has officially "ended" then we don't want to handle _any_ further messages related to it try MessageDeduplication.ensureMessageIsNotADuplicate( threadId: threadId, uniqueIdentifier: callMessage.uuid, @@ -219,12 +323,13 @@ public extension MessageDeduplication { static func insert( _ db: Database, processedMessage: ProcessedMessage, + ignoreDedupeFiles: Bool, using dependencies: Dependencies ) throws { typealias StandardInfo = ( threadVariant: SessionThread.Variant, message: Message, - serverExpirationTimestamp: Int64? + serverExpirationTimestamp: TimeInterval? ) let standardInfo: StandardInfo? = { @@ -234,7 +339,7 @@ public extension MessageDeduplication { return ( threadVariant, messageInfo.message, - messageInfo.serverExpirationTimestamp.map { Int64($0) } + messageInfo.serverExpirationTimestamp ) } }() @@ -247,6 +352,7 @@ public extension MessageDeduplication { legacyIdentifier: getLegacyIdentifier(for: processedMessage), message: standardInfo?.message, serverExpirationTimestamp: standardInfo?.serverExpirationTimestamp, + ignoreDedupeFiles: ignoreDedupeFiles, using: dependencies ) } @@ -274,7 +380,7 @@ private extension MessageDeduplication { legacyIdentifier: String?, legacyVariant: _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant?, timestampMs: Int64?, - serverExpirationTimestamp: Int64?, + serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws { typealias Variant = _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant @@ -286,8 +392,8 @@ private extension MessageDeduplication { let expirationTimestampSeconds: Int64? = { /// If we have a server expiration for the hash then we should use that value as the priority - if let serverExpirationTimestamp: Int64 = serverExpirationTimestamp { - return serverExpirationTimestamp + if let serverExpirationTimestamp: TimeInterval = serverExpirationTimestamp { + return Int64(serverExpirationTimestamp) } /// If we got here then it means we have no way to know when the message should expire but messages stored on @@ -304,7 +410,7 @@ private extension MessageDeduplication { /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds - .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + .map { $0 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000) } /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) @@ -371,3 +477,7 @@ private extension MessageDeduplication { } } } + +public extension CallMessage { + var preOfferDedupeIdentifier: String { "\(uuid)-preOffer" } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index ac4daf572f..0886620178 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -598,29 +598,6 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func isMessageRequest( - _ db: Database, - threadId: String, - userSessionId: SessionId, - includeNonVisible: Bool = false - ) -> Bool { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let request: SQLRequest = """ - SELECT \(thread[.id]) - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - WHERE ( - \(thread[.id]) = \(threadId) AND - \(SessionThread.isMessageRequest(userSessionId: userSessionId, includeNonVisible: includeNonVisible)) - ) - """ - - return ((try? request.fetchOne(db)) != nil) - } - static func unreadMessageRequestsCountQuery(userSessionId: SessionId, includeNonVisible: Bool = false) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 994ee1f86c..029bc87632 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -90,13 +90,23 @@ public enum AttachmentDownloadJob: JobExecutor { return dependencies[singleton: .storage] .readPublisher { db -> Network.PreparedRequest in - switch try OpenGroup.fetchOne(db, id: threadId) { - case .some(let openGroup): + let maybeRoomToken: String? = try OpenGroup + .select(.roomToken) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + + switch maybeRoomToken { + case .some(let roomToken): return try OpenGroupAPI.preparedDownload( - db, url: downloadUrl, - from: openGroup.roomToken, - on: openGroup.server, + roomToken: roomToken, + authMethod: try Authentication.with( + db, + threadId: threadId, + threadVariant: .community, + using: dependencies + ), using: dependencies ) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 7b9b80f9b3..40476cb7ad 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -49,68 +49,121 @@ public enum AttachmentUploadJob: JobExecutor { return deferred(job) } - // If this upload is related to sending a message then trigger the 'handleMessageWillSend' logic - // as if this is a retry the logic wouldn't run until after the upload has completed resulting in - // a potentially incorrect delivery status - dependencies[singleton: .storage].write { db in - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return } - - MessageSender.handleMessageWillSend( - db, - message: details.message, - destination: details.destination, - interactionId: interactionId - ) - } - - // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent - // reentrancy issues when the success/failure closures get called before the upload as the JobRunner - // will attempt to update the state of the job immediately + /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if this is a retry the + /// logic wouldn't run until after the upload has completed resulting in a potentially incorrect delivery status dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try attachment.preparedUpload(db, threadId: threadId, logCategory: .cat, using: dependencies) + .writePublisher { db -> AuthenticationMethod in + let threadVariant: SessionThread.Variant = try SessionThread + .select(.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return authMethod } + + MessageSender.handleMessageWillSend( + db, + message: details.message, + destination: details.destination, + interactionId: interactionId + ) + + return authMethod } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) + .tryMap { authMethod -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in + try AttachmentUploader.preparedUpload( + attachment: attachment, + logCategory: .cat, + authMethod: authMethod, + using: dependencies + ) + } + .flatMapStorageWritePublisher(using: dependencies) { db, uploadRequest -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in + /// If we have a `cachedResponse` (ie. already uploaded) then don't change the attachment state to uploading + /// as it's already been done + guard uploadRequest.cachedResponse == nil else { return uploadRequest } + + /// Update the attachment to the `uploading` state + _ = try? Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + + return uploadRequest + } + .flatMap { $0.send(using: dependencies) } + .map { _, value -> Attachment in value.attachment } + .handleEvents( + receiveCancel: { + /// If the stream gets cancelled then `receiveCompletion` won't get called, so we need to handle that + /// case and flag the upload as cancelled + dependencies[singleton: .storage].writeAsync { db in + try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + } + } + ) + .flatMapStorageWritePublisher(using: dependencies) { db, updatedAttachment in + /// Ensure there were changes before triggering a db write to avoid unneeded write queue use and UI updates + guard updatedAttachment != attachment else { return } + + try updatedAttachment.upserted(db) + } .sinkUntilComplete( receiveCompletion: { result in switch result { - case .failure(let error): - // If this upload is related to sending a message then trigger the - // 'handleFailedMessageSend' logic as we want to ensure the message - // has the correct delivery status - var didLogError: Bool = false - - dependencies[singleton: .storage].read { db in - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return } - - MessageSender.handleFailedMessageSend( - db, - message: details.message, - destination: nil, - error: .other(.cat, "Failed", error), - interactionId: interactionId, - using: dependencies - ) - didLogError = true - } - - // If we didn't log an error above then log it now - if !didLogError { Log.error(.cat, "Failed due to error: \(error)") } - failure(job, error, false) - case .finished: success(job, false) + + case .failure(let error): + dependencies[singleton: .storage].writeAsync( + updates: { db in + /// Update the attachment state + try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + + /// If this upload is related to sending a message then trigger the `handleFailedMessageSend` logic + /// as we want to ensure the message has the correct delivery status + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return false } + + MessageSender.handleFailedMessageSend( + db, + message: details.message, + destination: nil, + error: .other(.cat, "Failed", error), + interactionId: interactionId, + using: dependencies + ) + return true + }, + completion: { result in + /// If we didn't log an error above then log it now + switch result { + case .failure, .success(true): break + case .success(false): Log.error(.cat, "Failed due to error: \(error)") + } + + failure(job, error, false) + } + ) } } ) diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index 76b319e9b4..fae6eea756 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -93,7 +93,10 @@ public enum ConfigurationSyncJob: JobExecutor { Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingChanges.pushData.count), old hashes: \(pendingChanges.obsoleteHashes.count)") dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> AuthenticationMethod in + try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies) + } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Network.BatchResponse), Error> in try SnodeAPI.preparedSequence( requests: [] .appending(contentsOf: additionalTransientData?.beforeSequenceRequests) @@ -105,16 +108,12 @@ public enum ConfigurationSyncJob: JobExecutor { .preparedSendMessage( message: SnodeMessage( recipient: swarmPublicKey, - data: data.base64EncodedString(), + data: data, ttl: pushData.variant.ttl, timestampMs: UInt64(messageSendTimestamp) ), in: pushData.variant.namespace, - authMethod: try Authentication.with( - db, - swarmPublicKey: swarmPublicKey, - using: dependencies - ), + authMethod: authMethod, using: dependencies ) } @@ -126,11 +125,7 @@ public enum ConfigurationSyncJob: JobExecutor { return try SnodeAPI.preparedDeleteMessages( serverHashes: Array(pendingChanges.obsoleteHashes), requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: swarmPublicKey, - using: dependencies - ), + authMethod: authMethod, using: dependencies ) }()) @@ -140,9 +135,8 @@ public enum ConfigurationSyncJob: JobExecutor { snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .tryMap { (_: ResponseInfoType, response: Network.BatchResponse) -> [ConfigDump] in @@ -242,7 +236,9 @@ public enum ConfigurationSyncJob: JobExecutor { // Save the updated dumps to the database try configDumps.forEach { dump in try dump.upsert(db) - Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + Task { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } } // When we complete the 'ConfigurationSync' job we want to immediately schedule diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 67bd928843..a31d8f7e55 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -46,15 +46,20 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) case .community(let fileId, let roomToken, let server): - return dependencies[singleton: .storage].read { db in - try OpenGroupAPI.preparedDownload( - db, - fileId: fileId, - from: roomToken, - on: server, - using: dependencies - ) - } + guard + let info: LibSession.OpenGroupCapabilityInfo = dependencies[singleton: .storage] + .read({ db in + try LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) + }) + else { throw JobRunnerError.missingRequiredDetails } + + return try OpenGroupAPI.preparedDownload( + fileId: fileId, + roomToken: roomToken, + authMethod: Authentication.community(info: info), + using: dependencies + ) } }() else { return failure(job, JobRunnerError.missingRequiredDetails, true) } diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index 5b1ec4110d..8936dfc8f0 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -455,6 +455,7 @@ public enum GarbageCollectionJob: JobExecutor { .path ) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst catch { deletionErrors.append(error) } } @@ -478,6 +479,7 @@ public enum GarbageCollectionJob: JobExecutor { atPath: dependencies[singleton: .displayPictureManager].filepath(for: filename) ) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst catch { deletionErrors.append(error) } } @@ -495,6 +497,7 @@ public enum GarbageCollectionJob: JobExecutor { uniqueIdentifier: record.uniqueIdentifier ) } + catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst catch { deletionErrors.append(error) } } } diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index 1ffc16a8b6..c1729fdd3a 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -46,7 +46,7 @@ public enum GroupInviteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .writePublisher { db -> AuthenticationMethod in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -57,8 +57,10 @@ public enum GroupInviteMemberJob: JobExecutor { using: dependencies ) - return try MessageSender.preparedSend( - db, + return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( message: try GroupUpdateInviteMessage( inviteeSessionIdHexString: details.memberSessionIdHexString, groupSessionId: SessionId(.group, hex: threadId), @@ -70,21 +72,18 @@ public enum GroupInviteMemberJob: JobExecutor { profilePictureUrl: adminProfile.profilePictureUrl ), sentTimestampMs: UInt64(sentTimestampMs), - authMethod: try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ), + authMethod: authMethod, using: dependencies ), to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index f66682dadc..0cfed9cb39 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -37,7 +37,7 @@ public enum GroupLeavingJob: JobExecutor { let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) dependencies[singleton: .storage] - .writePublisher { db -> LeaveType in + .readPublisher(value: { db -> RequestType in guard (try? ClosedGroup.exists(db, id: threadId)) == true else { Log.error(.cat, "Failed due to non-existent group") throw MessageSenderError.invalidClosedGroupUpdate @@ -55,53 +55,22 @@ public enum GroupLeavingJob: JobExecutor { .distinct() .fetchCount(db)) .defaulting(to: 0) - let finalBehaviour: GroupLeavingJob.Details.Behaviour = { + let finalBehaviour: Details.Behaviour = { guard - dependencies.mutate(cache: .libSession, { cache in + !dependencies.mutate(cache: .libSession, { cache in cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) || cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) }) - else { return details.behaviour } + else { return .delete } - return .delete + return details.behaviour }() - switch (finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) { case (.leave, _, false): let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: threadId) + let authMethod: AuthenticationMethod = try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) - return .leave( - try SnodeAPI - .preparedBatch( - requests: [ - /// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based - /// message (it's an instruction for admin devices) - try MessageSender.preparedSend( - db, - message: GroupUpdateMemberLeftMessage(), - to: destination, - namespace: destination.defaultNamespace, - interactionId: job.interactionId, - fileIds: [], - using: dependencies - ), - try MessageSender.preparedSend( - db, - message: GroupUpdateMemberLeftNotificationMessage() - .with(disappearingConfig), - to: destination, - namespace: destination.defaultNamespace, - interactionId: nil, - fileIds: [], - using: dependencies - ) - ], - requireAllBatchResponses: false, - swarmPublicKey: threadId, - using: dependencies - ) - .map { _, _ in () } - ) + return .sendLeaveMessage(authMethod, disappearingConfig) case (.delete, true, _), (.leave, true, true): let groupSessionId: SessionId = SessionId(.group, hex: threadId) @@ -113,22 +82,51 @@ public enum GroupLeavingJob: JobExecutor { } } - return .delete + return .configSync - case (.delete, false, _): return .delete - + case (.delete, false, _): return .configSync default: throw MessageSenderError.invalidClosedGroupUpdate } - } - .flatMap { leaveType -> AnyPublisher in - switch leaveType { - case .leave(let leaveMessage): - return leaveMessage + }) + .tryFlatMap { requestType -> AnyPublisher in + switch requestType { + case .sendLeaveMessage(let authMethod, let disappearingConfig): + return try SnodeAPI + .preparedBatch( + requests: [ + /// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based + /// message (it's an instruction for admin devices) + try MessageSender.preparedSend( + message: GroupUpdateMemberLeftMessage(), + to: destination, + namespace: destination.defaultNamespace, + interactionId: job.interactionId, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ), + try MessageSender.preparedSend( + message: GroupUpdateMemberLeftNotificationMessage() + .with(disappearingConfig), + to: destination, + namespace: destination.defaultNamespace, + interactionId: nil, + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + ], + requireAllBatchResponses: false, + swarmPublicKey: threadId, + using: dependencies + ) .send(using: dependencies) .map { _ in () } .eraseToAnyPublisher() - case .delete: + case .configSync: return ConfigurationSyncJob .run(swarmPublicKey: threadId, using: dependencies) .map { _ in () } @@ -139,9 +137,9 @@ public enum GroupLeavingJob: JobExecutor { /// If it failed due to one of these errors then clear out any associated data (as the `SessionThread` exists but /// either the data required to send the `MEMBER_LEFT` message doesn't or the user has had their access to the /// group revoked which would leave the user in a state where they can't leave the group) - switch (error as? MessageSenderError, error as? SnodeAPIError) { - case (.invalidClosedGroupUpdate, _), (.noKeyPair, _), (.encryptionFailed, _), - (_, .unauthorised), (_, .invalidAuthentication): + switch (error as? MessageSenderError, error as? SnodeAPIError, error as? CryptoError) { + case (.invalidClosedGroupUpdate, _, _), (.noKeyPair, _, _), (.encryptionFailed, _, _), + (_, .unauthorised, _), (_, _, .invalidAuthentication): return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() default: throw error @@ -220,8 +218,8 @@ extension GroupLeavingJob { // MARK: - Convenience private extension GroupLeavingJob { - enum LeaveType { - case leave(Network.PreparedRequest) - case delete + enum RequestType { + case sendLeaveMessage(AuthenticationMethod, DisappearingMessagesConfiguration?) + case configSync } } diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index 86ce25d6b8..f09872b560 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -61,7 +61,7 @@ public enum GroupPromoteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .writePublisher { db -> AuthenticationMethod in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -72,17 +72,20 @@ public enum GroupPromoteMemberJob: JobExecutor { using: dependencies ) - return try MessageSender.preparedSend( - db, + return try Authentication.with(db, swarmPublicKey: details.memberSessionIdHexString, using: dependencies) + } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( message: message, to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index 65d3edb022..933c9883cb 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -28,13 +28,14 @@ public enum MessageSendJob: JobExecutor { using dependencies: Dependencies ) { guard + let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } /// We need to include `fileIds` when sending messages with attachments to Open Groups so extract them from any /// associated attachments - var messageFileIds: [String] = [] + var messageAttachments: [(attachment: Attachment, fileId: String)] = [] let messageType: String = { switch details.destination { case .syncMessage: return "\(type(of: details.message)) (SyncMessage)" @@ -46,7 +47,7 @@ public enum MessageSendJob: JobExecutor { switch job.behaviour { case .runOnceAfterConfigSyncIgnoringPermanentFailure: guard - let sessionId: SessionId = try? SessionId(from: job.threadId), + let sessionId: SessionId = try? SessionId(from: threadId), let variant: ConfigDump.Variant = details.requiredConfigSyncVariant else { return failure(job, JobRunnerError.missingRequiredDetails, true) } @@ -82,7 +83,7 @@ public enum MessageSendJob: JobExecutor { let interactionId: Int64 = job.interactionId else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - // Retrieve the current attachment state + /// Retrieve the current attachment state let attachmentState: AttachmentState = dependencies[singleton: .storage] .read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) } .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) @@ -150,8 +151,8 @@ public enum MessageSendJob: JobExecutor { return deferred(job) } - // Store the fileIds so they can be sent with the open group message content - messageFileIds = attachmentState.preparedFileIds + /// Store the fileIds so they can be sent with the open group message content + messageAttachments = attachmentState.preparedAttachments } /// If this message is being sent to an updated group then we should first make sure that we have a encryption keys @@ -202,18 +203,26 @@ public enum MessageSendJob: JobExecutor { /// **Note:** No need to upload attachments as part of this process as the above logic splits that out into it's own job /// so we shouldn't get here until attachments have already been uploaded dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in - try MessageSender.preparedSend( + .readPublisher(value: { db -> AuthenticationMethod in + try Authentication.with( db, + threadId: threadId, + threadVariant: details.destination.threadVariant, + using: dependencies + ) + }) + .tryFlatMap { authMethod in + try MessageSender.preparedSend( message: details.message, to: details.destination, namespace: details.destination.defaultNamespace, interactionId: job.interactionId, - fileIds: messageFileIds, + attachments: messageAttachments, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( @@ -268,19 +277,19 @@ public extension MessageSendJob { struct AttachmentState { public let error: Error? public let pendingUploadAttachmentIds: [String] - public let preparedFileIds: [String] public let allAttachmentIds: [String] + public let preparedAttachments: [(attachment: Attachment, fileId: String)] init( error: Error? = nil, pendingUploadAttachmentIds: [String] = [], - preparedFileIds: [String] = [], - allAttachmentIds: [String] = [] + allAttachmentIds: [String] = [], + preparedAttachments: [(Attachment, String)] = [] ) { self.error = error self.pendingUploadAttachmentIds = pendingUploadAttachmentIds - self.preparedFileIds = preparedFileIds self.allAttachmentIds = allAttachmentIds + self.preparedAttachments = preparedAttachments } } @@ -298,18 +307,14 @@ public extension MessageSendJob { let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment .stateInfo(interactionId: interactionId) .fetchAll(db) - let maybeFileIds: [String?] = allAttachmentStateInfo - .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } - .map { Attachment.fileId(for: $0.downloadUrl) } - let fileIds: [String] = maybeFileIds.compactMap { $0 } + let allAttachmentIds: [String] = allAttachmentStateInfo.map(\.attachmentId) // If there were failed attachments then this job should fail (can't send a // message which has associated attachments if the attachments fail to upload) guard !allAttachmentStateInfo.contains(where: { $0.state == .failedDownload }) else { return AttachmentState( error: AttachmentError.notUploaded, - preparedFileIds: fileIds, - allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId) + allAttachmentIds: allAttachmentIds ) } @@ -335,11 +340,25 @@ public extension MessageSendJob { } } .map { $0.attachmentId } + let preparedAttachmentIds: [String] = allAttachmentIds.filter { !pendingUploadAttachmentIds.contains($0) } + let attachments: [String: Attachment] = try Attachment + .fetchAll(db, ids: preparedAttachmentIds) + .reduce(into: [:]) { result, next in result[next.id] = next } + let preparedAttachments: [(Attachment, String)] = allAttachmentStateInfo + .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } + .compactMap { info in + guard + let attachment: Attachment = attachments[info.attachmentId], + let fileId: String = Attachment.fileId(for: info.downloadUrl) + else { return nil } + + return (attachment, fileId) + } return AttachmentState( pendingUploadAttachmentIds: pendingUploadAttachmentIds, - preparedFileIds: fileIds, - allAttachmentIds: allAttachmentStateInfo.map(\.attachmentId) + allAttachmentIds: allAttachmentIds, + preparedAttachments: preparedAttachments ) } } diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index e20ec4452e..fa2ee59eb8 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -135,7 +135,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { .preparedSendMessage( message: SnodeMessage( recipient: groupSessionId.hexString, - data: encryptedDeleteMessageData.base64EncodedString(), + data: encryptedDeleteMessageData, ttl: Message().ttl, timestampMs: UInt64(messageSendTimestamp) ), @@ -153,26 +153,29 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { let preparedMemberContentRemovalMessage: Network.PreparedRequest? = { () -> Network.PreparedRequest? in guard !memberIdsToRemoveContent.isEmpty else { return nil } - return dependencies[singleton: .storage].write { db in - try MessageSender.preparedSend( - db, - message: GroupUpdateDeleteMemberContentMessage( - memberSessionIds: Array(memberIdsToRemoveContent), - messageHashes: [], - sentTimestampMs: UInt64(targetChangeTimestampMs), - authMethod: Authentication.groupAdmin( - groupSessionId: groupSessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) - ), - using: dependencies + return try? MessageSender.preparedSend( + message: GroupUpdateDeleteMemberContentMessage( + memberSessionIds: Array(memberIdsToRemoveContent), + messageHashes: [], + sentTimestampMs: UInt64(targetChangeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) ), - to: .closedGroup(groupPublicKey: groupSessionId.hexString), - namespace: .groupMessages, - interactionId: nil, - fileIds: [], using: dependencies - ) - } + ), + to: .closedGroup(groupPublicKey: groupSessionId.hexString), + namespace: .groupMessages, + interactionId: nil, + attachments: nil, + authMethod: Authentication.groupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ) + .map { _, _ in () } }() /// Combine the two requests to be sent at the same time diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index b6e9f32ff2..3bccbb42f6 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -61,14 +61,20 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { /// Try to retrieve the default rooms 8 times dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> Network.PreparedRequest in - try OpenGroupAPI.preparedCapabilitiesAndRooms( + .readPublisher { [dependencies] db -> AuthenticationMethod in + try Authentication.with( db, - on: OpenGroupAPI.defaultServer, + server: OpenGroupAPI.defaultServer, + activeOnly: false, /// The record for the default rooms is inactive using: dependencies ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } + .tryFlatMap { [dependencies] authMethod -> AnyPublisher<(ResponseInfoType, OpenGroupAPI.CapabilitiesAndRoomsResponse), Error> in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: authMethod, + using: dependencies + ).send(using: dependencies) + } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .retry(8, using: dependencies) diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index 62d24d07bc..e5eaf7bb9e 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -34,20 +34,21 @@ public enum SendReadReceiptsJob: JobExecutor { } dependencies[singleton: .storage] - .writePublisher { db -> Network.PreparedRequest in + .readPublisher { db in try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) } + .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in try MessageSender.preparedSend( - db, message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), to: details.destination, namespace: details.destination.defaultNamespace, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ) + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index b9ecfce7b7..0c46b6c3be 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -324,7 +324,9 @@ internal extension LibSession { ) try dump?.upsert(db) - Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + Task { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } } } diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 1931735829..5964bbb7e5 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -546,7 +546,9 @@ public extension LibSession { timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) try dump?.upsert(db) - Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + Task { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } } } catch { @@ -792,7 +794,9 @@ public extension LibSession { timestampMs: latestServerTimestampMs ) try dump?.upsert(db) - Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + Task { [extensionHelper = dependencies[singleton: .extensionHelper]] in + extensionHelper.replicate(dump: dump) + } } // Now that the local state has been updated, schedule a config sync if needed (this will @@ -1005,6 +1009,35 @@ public extension LibSessionCacheType { try withCustomBehaviour(behaviour, for: sessionId, variant: nil, change: change) } + func performAndPushChange( + _ db: Database, + for variant: ConfigDump.Variant, + sessionId: SessionId, + change: @escaping () throws -> () + ) throws { + try performAndPushChange(db, for: variant, sessionId: sessionId, change: { _ in try change() }) + } + + func performAndPushChange( + _ db: Database, + for variant: ConfigDump.Variant, + change: @escaping (LibSession.Config?) throws -> () + ) throws { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + try performAndPushChange(db, for: variant, sessionId: userSessionId, change: change) + } + + func performAndPushChange( + _ db: Database, + for variant: ConfigDump.Variant, + change: @escaping () throws -> () + ) throws { + guard ConfigDump.Variant.userVariants.contains(variant) else { throw LibSessionError.invalidConfigAccess } + + try performAndPushChange(db, for: variant, sessionId: userSessionId, change: { _ in try change() }) + } + func loadState(_ db: Database) { loadState(db, requestId: nil) } diff --git a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift index 7e7d42f30f..93ba33cded 100644 --- a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift +++ b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift @@ -43,8 +43,62 @@ public extension LibSession { var publicKey: String { urlInfo.publicKey } let capabilities: Set + // MARK: - Initialization + + init( + urlInfo: OpenGroupUrlInfo, + capabilities: Set + ) { + self.urlInfo = urlInfo + self.capabilities = capabilities + } + + public init( + roomToken: String, + server: String, + publicKey: String, + capabilities: Set + ) { + self.urlInfo = OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: publicKey + ) + self.capabilities = capabilities + } + // MARK: - Queries + public static func fetchOne(_ db: Database, server: String, activeOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { + var query: QueryInterfaceRequest = OpenGroup + .select(.threadId, .server, .roomToken, .publicKey) + .filter(OpenGroup.Columns.server == server.lowercased()) + .asRequest(of: OpenGroupUrlInfo.self) + + /// If we only want to retrieve data for active OpenGroups then add additional filters + if activeOnly { + query = query + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + } + + guard let urlInfo: OpenGroupUrlInfo = try query.fetchOne(db) else { return nil } + + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == urlInfo.server.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) + + return OpenGroupCapabilityInfo( + urlInfo: urlInfo, + capabilities: capabilities + ) + } + public static func fetchOne(_ db: Database, id: String) throws -> OpenGroupCapabilityInfo? { let maybeUrlInfo: OpenGroupUrlInfo? = try OpenGroup .filter(id: id) @@ -57,6 +111,7 @@ public extension LibSession { let capabilities: Set = (try? Capability .select(.variant) .filter(Capability.Columns.openGroupServer == urlInfo.server.lowercased()) + .filter(Capability.Columns.isMissing == false) .asRequest(of: Capability.Variant.self) .fetchSet(db)) .defaulting(to: []) diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index 85a06d8e6d..63135fe9b0 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit /// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information. @@ -30,7 +29,7 @@ public final class CallMessage: ControlMessage { // MARK: - Kind /// **Note:** Multiple ICE candidates may be batched together for performance - public enum Kind: Codable, CustomStringConvertible { + public enum Kind: Codable, Equatable, CustomStringConvertible { private enum CodingKeys: String, CodingKey { case description case sdpMLineIndexes @@ -168,7 +167,7 @@ public final class CallMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let type: SNProtoCallMessage.SNProtoCallMessageType switch kind { @@ -224,7 +223,7 @@ public final class CallMessage: ControlMessage { public extension CallMessage { struct MessageInfo: Codable { - public enum State: Codable { + public enum State: Codable, CaseIterable { case incoming case outgoing case missed diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index 50b4315990..c5b6e79e76 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class DataExtractionNotification: ControlMessage { @@ -83,7 +82,7 @@ public final class DataExtractionNotification: ControlMessage { return DataExtractionNotification(kind: kind) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let kind = kind else { Log.warn(.messageSender, "Couldn't construct data extraction notification proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index bc33f2f113..60198af3c0 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class ExpirationTimerUpdate: ControlMessage { @@ -50,7 +49,7 @@ public final class ExpirationTimerUpdate: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let dataMessageProto = SNProtoDataMessage.builder() dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift index 883fd6891c..65520d7125 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { @@ -118,7 +117,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let deleteMemberContentMessageBuilder: SNProtoGroupUpdateDeleteMemberContentMessage.SNProtoGroupUpdateDeleteMemberContentMessageBuilder = SNProtoGroupUpdateDeleteMemberContentMessage.builder() deleteMemberContentMessageBuilder.setMemberSessionIds(memberSessionIds) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift index 8d777e7681..666b233755 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInfoChangeMessage: ControlMessage { @@ -130,7 +129,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let infoChangeMessageBuilder: SNProtoGroupUpdateInfoChangeMessage.SNProtoGroupUpdateInfoChangeMessageBuilder = SNProtoGroupUpdateInfoChangeMessage.builder( type: { diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift index 54b7d6d19d..02fc2de762 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInviteMessage: ControlMessage { @@ -139,7 +138,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let inviteMessageBuilder: SNProtoGroupUpdateInviteMessage.SNProtoGroupUpdateInviteMessageBuilder = SNProtoGroupUpdateInviteMessage.builder( groupSessionID: groupSessionId.hexString, // Include the prefix diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift index bab78e074d..4282dcb367 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteResponseMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateInviteResponseMessage: ControlMessage { @@ -64,7 +63,7 @@ public final class GroupUpdateInviteResponseMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let inviteResponseMessageBuilder: SNProtoGroupUpdateInviteResponseMessage.SNProtoGroupUpdateInviteResponseMessageBuilder = SNProtoGroupUpdateInviteResponseMessage.builder( isApproved: isApproved diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift index 0c2a49ccc6..62842b9c38 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberChangeMessage: ControlMessage { @@ -130,7 +129,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberChangeMessageBuilder: SNProtoGroupUpdateMemberChangeMessage.SNProtoGroupUpdateMemberChangeMessageBuilder = SNProtoGroupUpdateMemberChangeMessage.builder( type: { diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift index a2a33c9d79..87944f8ecf 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberLeftMessage: ControlMessage { @@ -28,7 +27,7 @@ public final class GroupUpdateMemberLeftMessage: ControlMessage { return GroupUpdateMemberLeftMessage() } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberLeftMessageBuilder: SNProtoGroupUpdateMemberLeftMessage.SNProtoGroupUpdateMemberLeftMessageBuilder = SNProtoGroupUpdateMemberLeftMessage.builder() diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift index a4aff61976..c3f5beb513 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberLeftNotificationMessage.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdateMemberLeftNotificationMessage: ControlMessage { @@ -28,7 +27,7 @@ public final class GroupUpdateMemberLeftNotificationMessage: ControlMessage { return GroupUpdateMemberLeftNotificationMessage() } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let memberLeftNotificationMessageBuilder: SNProtoGroupUpdateMemberLeftNotificationMessage.SNProtoGroupUpdateMemberLeftNotificationMessageBuilder = SNProtoGroupUpdateMemberLeftNotificationMessage.builder() diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift index 12c076d2c3..2485c86fa2 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdatePromoteMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class GroupUpdatePromoteMessage: ControlMessage { @@ -71,7 +70,7 @@ public final class GroupUpdatePromoteMessage: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { do { let promoteMessageBuilder: SNProtoGroupUpdatePromoteMessage.SNProtoGroupUpdatePromoteMessageBuilder = SNProtoGroupUpdatePromoteMessage.builder( groupIdentitySeed: groupIdentitySeed, diff --git a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift index 407c303e27..ce56c468a9 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class MessageRequestResponse: ControlMessage { @@ -59,7 +58,7 @@ public final class MessageRequestResponse: ControlMessage { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let messageRequestResponseProto: SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder // Profile diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index 5a154285fe..d051d4a0db 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class ReadReceipt: ControlMessage { @@ -54,7 +53,7 @@ public final class ReadReceipt: ControlMessage { return ReadReceipt(timestamps: timestamps) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestamps = timestamps else { Log.warn(.messageSender, "Couldn't construct read receipt proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index f6ca3d3c71..79360f68bf 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class TypingIndicator: ControlMessage { @@ -82,7 +81,7 @@ public final class TypingIndicator: ControlMessage { return TypingIndicator(kind: kind) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestampMs = sentTimestampMs, let kind = kind else { Log.warn(.messageSender, "Couldn't construct typing indicator proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 711abb7d5b..00f72c1264 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class UnsendRequest: ControlMessage { @@ -61,7 +60,7 @@ public final class UnsendRequest: ControlMessage { return UnsendRequest(timestamp: timestamp, author: author) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { guard let timestamp = timestamp, let author = author else { Log.warn(.messageSender, "Couldn't construct unsend request proto from: \(self).") return nil diff --git a/SessionMessagingKit/Messages/LibSessionMessage.swift b/SessionMessagingKit/Messages/LibSessionMessage.swift index b661d355a5..3e391fd9f7 100644 --- a/SessionMessagingKit/Messages/LibSessionMessage.swift +++ b/SessionMessagingKit/Messages/LibSessionMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class LibSessionMessage: Message, NotProtoConvertible { diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 507b72c10f..71d8073f26 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -61,7 +61,7 @@ public extension Message { if prefix == .blinded15 || prefix == .blinded25 { guard let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: threadId) else { - preconditionFailure("Attempting to send message to blinded id without the Open Group information") + throw OpenGroupAPIError.blindedLookupMissingCommunityInfo } return .openGroupInbox( @@ -76,11 +76,12 @@ public extension Message { case .legacyGroup, .group: return .closedGroup(groupPublicKey: threadId) case .community: - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - throw StorageError.objectNotFound - } + guard + let info: LibSession.OpenGroupUrlInfo = try? LibSession.OpenGroupUrlInfo + .fetchOne(db, id: threadId) + else { throw StorageError.objectNotFound } - return .openGroup(roomToken: openGroup.roomToken, server: openGroup.server) + return .openGroup(roomToken: info.roomToken, server: info.server) } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 713a7d8c26..ca4a38c354 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -120,8 +120,8 @@ public class Message: Codable { preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } - public func toProto(_ db: Database, threadId: String) -> SNProtoContent? { - preconditionFailure("toProto(_:) is abstract and must be overridden.") + public func toProto() -> SNProtoContent? { + preconditionFailure("toProto() is abstract and must be overridden.") } public func setDisappearingMessagesConfigurationIfNeeded(on proto: SNProtoContent.SNProtoContentBuilder) { diff --git a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift new file mode 100644 index 0000000000..6d9d5f3196 --- /dev/null +++ b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift @@ -0,0 +1,127 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension SNProtoContent { + /// Add attachment proto information if required + /// + /// **Note:** This function expects the `attachments` array to be sorted in a way that matches the order of the + /// `InteractionAttachment` values + func addingAttachmentsIfNeeded( + _ message: Message, + _ attachments: [Attachment]? = nil + ) throws -> SNProtoContent? { + guard + let message: VisibleMessage = message as? VisibleMessage, ( + !message.attachmentIds.isEmpty || + message.quote?.attachmentId != nil || + message.linkPreview?.attachmentId != nil + ) + else { return self } + + /// Calculate attachment information + let expectedAttachmentUploadCount: Int = ( + message.attachmentIds.count + + (message.linkPreview?.attachmentId != nil ? 1 : 0) + + (message.quote?.attachmentId != nil ? 1 : 0) + ) + let uniqueAttachmentIds: Set = Set(message.attachmentIds) + .inserting(message.linkPreview?.attachmentId) + .inserting(message.quote?.attachmentId) + + /// We need to ensure we don't send a message which should have uploaded files but hasn't, we do this by comparing the + /// `attachmentIds` on the `VisibleMessage` to the `attachments` value + guard expectedAttachmentUploadCount == (attachments?.count ?? 0) else { + throw MessageSenderError.attachmentsNotUploaded + } + + /// Ensure we haven't incorrectly included the `linkPreview` or `quote` attachments in the main `attachmentIds` + guard uniqueAttachmentIds.count == expectedAttachmentUploadCount else { + throw MessageSenderError.attachmentsInvalid + } + + do { + var processedAttachments: [Attachment] = (attachments ?? []) + + /// Recreate the builder for the proto + guard let dataMessage = dataMessage?.asBuilder() else { + Log.warn(.messageSender, "Couldn't recreate dataMessage builder from: \(message).") + return nil + } + + let builder = self.asBuilder() + var attachmentIds: [String] = message.attachmentIds + + /// Quote + if let attachmentId: String = message.quote?.attachmentId { + if let index: Array.Index = attachmentIds.firstIndex(of: attachmentId) { + attachmentIds.remove(at: index) + } + + if + let quoteBuilder = self.dataMessage?.quote?.asBuilder(), + let attachment: Attachment = processedAttachments.first(where: { $0.id == attachmentId }) + { + let attachmentProtoBuilder = SNProtoDataMessageQuoteQuotedAttachment.builder() + attachmentProtoBuilder.setContentType(attachment.contentType) + + if let fileName = attachment.sourceFilename { + attachmentProtoBuilder.setFileName(fileName) + } + + if + attachment.state == .uploaded, + let attachmentProto = attachment.buildProto() + { + attachmentProtoBuilder.setThumbnail(attachmentProto) + } + else { + Log.warn(.messageSender, "Ignoring invalid attachment for quoted message.") + } + + do { + try quoteBuilder.addAttachments(attachmentProtoBuilder.build()) + try dataMessage.setQuote(quoteBuilder.build()) + } + catch { + Log.warn(.messageSender, "Couldn't construct quoted attachment proto from: \(message).") + } + } + + /// Remove the `quote` attachment from the general attachments set + processedAttachments = processedAttachments.filter { $0.id != attachmentId } + } + + /// Link preview + if let attachmentId: String = message.linkPreview?.attachmentId { + if let index: Array.Index = attachmentIds.firstIndex(of: attachmentId) { + attachmentIds.remove(at: index) + } + + if + let linkPreviewBuilder = self.dataMessage?.preview.first?.asBuilder(), + let attachment: Attachment = processedAttachments.first(where: { $0.id == attachmentId }), + let attachmentProto = attachment.buildProto() + { + linkPreviewBuilder.setImage(attachmentProto) + try dataMessage.setPreview([ linkPreviewBuilder.build() ]) + } + + /// Remove the `linkPreview` attachment from the general attachments set + processedAttachments = processedAttachments.filter { $0.id != attachmentId } + } + + /// Attachments + let attachmentProtos = processedAttachments.compactMap { $0.buildProto() } + dataMessage.setAttachments(attachmentProtos) + + /// Build + builder.setDataMessage(try dataMessage.build()) + return try builder.build() + } catch { + Log.warn(.messageSender, "Couldn't add attachments to proto from: \(message).") + return nil + } + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index bc7adb3f0c..3d64e8e482 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { @@ -31,10 +30,6 @@ public extension VisibleMessage { } public func toProto() -> SNProtoDataMessagePreview? { - preconditionFailure("Use toProto(using:) instead.") - } - - public func toProto(_ db: Database) -> SNProtoDataMessagePreview? { guard let url = url else { Log.warn(.messageSender, "Couldn't construct link preview proto from: \(self).") return nil @@ -42,14 +37,6 @@ public extension VisibleMessage { let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url) if let title = title { linkPreviewProto.setTitle(title) } - if - let attachmentId = attachmentId, - let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), - let attachmentProto = attachment.buildProto() - { - linkPreviewProto.setImage(attachmentProto) - } - do { return try linkPreviewProto.build() } catch { @@ -75,7 +62,7 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMLinkPreview { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { + static func from(linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { return VisibleMessage.VMLinkPreview( title: linkPreview.title, url: linkPreview.url, diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift index f0e3edbae1..eb94c6942c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { @@ -55,7 +54,7 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMOpenGroupInvitation { - static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { + static func from(linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { guard let name: String = linkPreview.title else { return nil } return VisibleMessage.VMOpenGroupInvitation( diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 9e143d36e6..b8830c7c34 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { @@ -35,17 +34,12 @@ public extension VisibleMessage { } public func toProto() -> SNProtoDataMessageQuote? { - preconditionFailure("Use toProto(_:) instead.") - } - - public func toProto(_ db: Database) -> SNProtoDataMessageQuote? { guard let timestamp = timestamp, let authorId = authorId else { Log.warn(.messageSender, "Couldn't construct quote proto from: \(self).") return nil } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) if let text = text { quoteProto.setText(text) } - addAttachmentsIfNeeded(db, to: quoteProto) do { return try quoteProto.build() } catch { @@ -53,32 +47,6 @@ public extension VisibleMessage { return nil } } - - private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { - guard let attachmentId = attachmentId else { return } - guard - let attachment: Attachment = try? Attachment.fetchOne(db, id: attachmentId), - attachment.state == .uploaded - else { - #if DEBUG - preconditionFailure("Sending a message before all associated attachments have been uploaded.") - #else - return - #endif - } - let quotedAttachmentProto = SNProtoDataMessageQuoteQuotedAttachment.builder() - quotedAttachmentProto.setContentType(attachment.contentType) - if let fileName = attachment.sourceFilename { quotedAttachmentProto.setFileName(fileName) } - guard let attachmentProto = attachment.buildProto() else { - return Log.warn(.messageSender, "Ignoring invalid attachment for quoted message.") - } - quotedAttachmentProto.setThumbnail(attachmentProto) - do { - try quoteProto.addAttachments(quotedAttachmentProto.build()) - } catch { - Log.warn(.messageSender, "Couldn't construct quoted attachment proto from: \(self).") - } - } // MARK: - Description @@ -98,7 +66,7 @@ public extension VisibleMessage { // MARK: - Database Type Conversion public extension VisibleMessage.VMQuote { - static func from(_ db: Database, quote: Quote) -> VisibleMessage.VMQuote { + static func from(quote: Quote) -> VisibleMessage.VMQuote { return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), authorId: quote.authorId, diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift index edf541920a..7f7b3e63b1 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public extension VisibleMessage { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 3c75d60e83..6fe8111fdc 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit public final class VisibleMessage: Message { @@ -126,11 +125,10 @@ public final class VisibleMessage: Message { ) } - public override func toProto(_ db: Database, threadId: String) -> SNProtoContent? { + public override func toProto() -> SNProtoContent? { let proto = SNProtoContent.builder() if let sigTimestampMs = sigTimestampMs { proto.setSigTimestamp(sigTimestampMs) } - var attachmentIds = self.attachmentIds let dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder // Profile @@ -146,11 +144,7 @@ public final class VisibleMessage: Message { // Quote - if let quotedAttachmentId = quote?.attachmentId, let index = attachmentIds.firstIndex(of: quotedAttachmentId) { - attachmentIds.remove(at: index) - } - - if let quote = quote, let quoteProto = quote.toProto(db) { + if let quote = quote, let quoteProto = quote.toProto() { dataMessage.setQuote(quoteProto) } @@ -159,23 +153,10 @@ public final class VisibleMessage: Message { attachmentIds.remove(at: index) } - if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(db) { + if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto() { dataMessage.setPreview([ linkPreviewProto ]) } - // Attachments - - let attachmentIdIndexes: [String: Int] = (try? InteractionAttachment - .filter(self.attachmentIds.contains(InteractionAttachment.Columns.attachmentId)) - .fetchAll(db)) - .defaulting(to: []) - .reduce(into: [:]) { result, next in result[next.attachmentId] = next.albumIndex } - let attachments: [Attachment] = (try? Attachment.fetchAll(db, ids: self.attachmentIds)) - .defaulting(to: []) - .sorted { lhs, rhs in (attachmentIdIndexes[lhs.id] ?? 0) < (attachmentIdIndexes[rhs.id] ?? 0) } - let attachmentProtos = attachments.compactMap { $0.buildProto() } - dataMessage.setAttachments(attachmentProtos) - // Open group invitation if let openGroupInvitation = openGroupInvitation, @@ -223,47 +204,3 @@ public final class VisibleMessage: Message { """ } } - -// MARK: - Database Type Conversion - -public extension VisibleMessage { - static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { - let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) - - let visibleMessage: VisibleMessage = VisibleMessage( - sender: interaction.authorId, - sentTimestampMs: UInt64(interaction.timestampMs), - syncTarget: nil, - text: interaction.body, - attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) - .map { $0.id }, - quote: (try? interaction.quote.fetchOne(db)) - .map { VMQuote.from(db, quote: $0) }, - linkPreview: linkPreview - .map { linkPreview in - guard linkPreview.variant == .standard else { return nil } - - return VMLinkPreview.from(db, linkPreview: linkPreview) - }, - profile: nil, // Don't attach the profile to avoid sending a legacy version (set in MessageSender) - openGroupInvitation: linkPreview.map { linkPreview in - guard linkPreview.variant == .openGroupInvitation else { return nil } - - return VMOpenGroupInvitation.from( - db, - linkPreview: linkPreview - ) - }, - reaction: nil // Reactions are custom messages sent separately - ) - .with( - expiresInSeconds: interaction.expiresInSeconds, - expiresStartedAtMs: interaction.expiresStartedAtMs - ) - - visibleMessage.expiresInSeconds = interaction.expiresInSeconds - visibleMessage.expiresStartedAtMs = interaction.expiresStartedAtMs - - return visibleMessage - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 43d8927c8e..dcdbe70fa3 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -3,12 +3,16 @@ // stringlint:disable import Foundation -import Combine -import GRDB import SessionSnodeKit import SessionUtilitiesKit public enum OpenGroupAPI { + public struct RoomInfo: Codable { + let roomToken: String + let infoUpdates: Int64 + let sequenceNumber: Int64 + } + // MARK: - Settings public static let legacyDefaultServerIP = "116.203.70.33" @@ -29,49 +33,29 @@ public enum OpenGroupAPI { /// - Inbox for the server /// - Outbox for the server public static func preparedPoll( - _ db: Database, - server: String, + roomInfo: [RoomInfo], + lastInboxMessageId: Int64, + lastOutboxMessageId: Int64, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { - let lastInboxMessageId: Int64 = (try? OpenGroup - .select(.inboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - let lastOutboxMessageId: Int64 = (try? OpenGroup - .select(.outboxLatestMessageId) - .filter(OpenGroup.Columns.server == server) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - let openGroupRooms: [OpenGroup] = (try? OpenGroup - .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .fetchAll(db)) - .defaulting(to: []) - + guard case .community(_, _, _, let supportsBlinding, _) = authMethod.info else { + throw NetworkError.invalidPreparedRequest + } + let preparedRequests: [any ErasedPreparedRequest] = [ try preparedCapabilities( - db, - server: server, + authMethod: authMethod, using: dependencies ) ].appending( // Per-room requests - contentsOf: try openGroupRooms - .flatMap { openGroup -> [any ErasedPreparedRequest] in + contentsOf: try roomInfo + .flatMap { roomInfo -> [any ErasedPreparedRequest] in let shouldRetrieveRecentMessages: Bool = ( - openGroup.sequenceNumber == 0 || ( + roomInfo.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than // 'maxInactivityPeriod' then just retrieve recent messages instead // of trying to get all messages since the last one retrieved @@ -82,24 +66,21 @@ public enum OpenGroupAPI { return [ try preparedRoomPollInfo( - db, - lastUpdated: openGroup.infoUpdates, - for: openGroup.roomToken, - on: openGroup.server, + lastUpdated: roomInfo.infoUpdates, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ), (shouldRetrieveRecentMessages ? try preparedRecentMessages( - db, - in: openGroup.roomToken, - on: openGroup.server, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ) : try preparedMessagesSince( - db, - seqNo: openGroup.sequenceNumber, - in: openGroup.roomToken, - on: openGroup.server, + seqNo: roomInfo.sequenceNumber, + roomToken: roomInfo.roomToken, + authMethod: authMethod, using: dependencies ) ) @@ -109,20 +90,28 @@ public enum OpenGroupAPI { .appending( contentsOf: ( // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded - !capabilities.contains(.blind) ? [] : + !supportsBlinding ? [] : [ // Inbox (only check the inbox if the user want's community message requests) - (!db[.checkForCommunityMessageRequests] ? nil : + (!dependencies.mutate(cache: .libSession) { $0.get(.checkForCommunityMessageRequests) } ? nil : (lastInboxMessageId == 0 ? - try preparedInbox(db, on: server, using: dependencies) : - try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + try preparedInbox(authMethod: authMethod, using: dependencies) : + try preparedInboxSince( + id: lastInboxMessageId, + authMethod: authMethod, + using: dependencies + ) ) ), // Outbox (lastOutboxMessageId == 0 ? - try preparedOutbox(db, on: server, using: dependencies) : - try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) + try preparedOutbox(authMethod: authMethod, using: dependencies) : + try preparedOutboxSince( + id: lastOutboxMessageId, + authMethod: authMethod, + using: dependencies + ) ), ].compactMap { $0 } ) @@ -130,12 +119,11 @@ public enum OpenGroupAPI { return try OpenGroupAPI .preparedBatch( - db, - server: server, requests: preparedRequests, + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one @@ -146,24 +134,22 @@ public enum OpenGroupAPI { /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided /// with the request body. public static func preparedBatch( - _ db: Database, - server: String, requests: [any ErasedPreparedRequest], + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.batch, - body: Network.BatchRequest(requests: requests) + body: Network.BatchRequest(requests: requests), + authMethod: authMethod ), responseType: Network.BatchResponseMap.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests @@ -177,24 +163,22 @@ public enum OpenGroupAPI { /// list (if requests were stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final /// response value private static func preparedSequence( - _ db: Database, - server: String, requests: [any ErasedPreparedRequest], + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.sequence, - body: Network.BatchRequest(requests: requests) + body: Network.BatchRequest(requests: requests), + authMethod: authMethod ), responseType: Network.BatchResponseMap.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Capabilities @@ -207,25 +191,19 @@ public enum OpenGroupAPI { /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func preparedCapabilities( - _ db: Database, - server: String, - forceBlinded: Bool = false, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .capabilities + endpoint: .capabilities, + authMethod: authMethod ), responseType: Capabilities.self, - additionalSignatureData: AdditionalSigningData( - server: server, - forceBlinded: forceBlinded - ), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Room @@ -234,41 +212,37 @@ public enum OpenGroupAPI { /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func preparedRooms( - _ db: Database, - server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Room]> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .rooms + endpoint: .rooms, + authMethod: authMethod ), responseType: [Room].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Returns the details of a single room public static func preparedRoom( - _ db: Database, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .room(roomToken) + endpoint: .room(roomToken), + authMethod: authMethod ), responseType: Room.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls a room for metadata updates @@ -276,23 +250,21 @@ public enum OpenGroupAPI { /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value public static func preparedRoomPollInfo( - _ db: Database, lastUpdated: Int64, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomPollInfo(roomToken, lastUpdated) + endpoint: .roomPollInfo(roomToken, lastUpdated), + authMethod: authMethod ), responseType: RoomPollInfo.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } public typealias CapabilitiesAndRoomResponse = ( @@ -303,24 +275,22 @@ public enum OpenGroupAPI { /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRoom( - _ db: Database, - for roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ // Get the latest capabilities for the server (in case it's a new server or the // cached ones are stale) - preparedCapabilities(db, server: server, using: dependencies), - preparedRoom(db, for: roomToken, on: server, using: dependencies) + preparedCapabilities(authMethod: authMethod, using: dependencies), + preparedRoom(roomToken: roomToken, authMethod: authMethod, using: dependencies) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) .tryMap { (info: ResponseInfoType, response: Network.BatchResponseMap) -> CapabilitiesAndRoomResponse in let maybeCapabilities: Network.BatchSubResponse? = (response[.capabilities] as? Network.BatchSubResponse) let maybeRoomResponse: Any? = response.data @@ -355,23 +325,21 @@ public enum OpenGroupAPI { /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRooms( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ // Get the latest capabilities for the server (in case it's a new server or the // cached ones are stale) - preparedCapabilities(db, server: server, using: dependencies), - preparedRooms(db, server: server, using: dependencies) + preparedCapabilities(authMethod: authMethod, using: dependencies), + preparedRooms(authMethod: authMethod, using: dependencies) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) .tryMap { (info: ResponseInfoType, response: Network.BatchResponseMap) -> CapabilitiesAndRoomsResponse in let maybeCapabilities: Network.BatchSubResponse? = (response[.capabilities] as? Network.BatchSubResponse) let maybeRooms: Network.BatchSubResponse<[Room]>? = response.data @@ -403,28 +371,24 @@ public enum OpenGroupAPI { /// Posts a new message to a room public static func preparedSend( - _ db: Database, plaintext: Data, - to roomToken: String, - on server: String, + roomToken: String, whisperTo: String?, whisperMods: Bool, fileIds: [String]?, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: plaintext.bytes, - for: server, + authMethod: authMethod, fallbackSigningType: .standard, using: dependencies ) return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.roomMessage(roomToken), body: SendMessageRequest( data: plaintext, @@ -432,95 +396,89 @@ public enum OpenGroupAPI { whisperTo: whisperTo, whisperMods: whisperMods, fileIds: fileIds - ) + ), + authMethod: authMethod ), responseType: Message.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Returns a single message by ID public static func preparedMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) + endpoint: .roomMessageIndividual(roomToken, id: id), + authMethod: authMethod ), responseType: Message.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Edits a message, replacing its existing content with new content and a new signature /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func preparedMessageUpdate( - _ db: Database, id: Int64, plaintext: Data, fileIds: [Int64]?, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: plaintext.bytes, - for: server, + authMethod: authMethod, fallbackSigningType: .standard, using: dependencies ) return try Network.PreparedRequest( request: Request( - db, method: .put, - server: server, endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), body: UpdateMessageRequest( data: plaintext, signature: Data(signResult.signature), fileIds: fileIds - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove a message by its message id public static func preparedMessageDelete( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .roomMessageIndividual(roomToken, id: id) + endpoint: .roomMessageIndividual(roomToken, id: id), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves recent messages posted to this room @@ -529,26 +487,24 @@ public enum OpenGroupAPI { /// versions: that is, deleted message indicators and pre-editing versions of messages are not returned. Messages are returned in order /// from most recent to least recent public static func preparedRecentMessages( - _ db: Database, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesRecent(roomToken), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves messages from the room preceding a given id. @@ -558,27 +514,25 @@ public enum OpenGroupAPI { /// /// As with .../recent, this endpoint does not include deleted messages and always returns the current version, for edited messages. public static func preparedMessagesBefore( - _ db: Database, messageId: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves message updates from a room. This is the main message polling endpoint in SOGS. @@ -588,27 +542,25 @@ public enum OpenGroupAPI { /// to existing messages (i.e. edits), and message deletions made to the room since the given update id. Messages are returned in "update" /// order, that is, in the order in which the change was applied to the room, from oldest the newest. public static func preparedMessagesSince( - _ db: Database, seqNo: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[Failable]> { return try Network.PreparedRequest( request: Request( - db, - server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" - ] + ], + authMethod: authMethod ), responseType: [Failable].self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server @@ -625,35 +577,32 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedMessagesDeleteAll( - _ db: Database, sessionId: String, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Reactions /// Returns the list of all reactors who have added a particular reaction to a particular message. public static func preparedReactors( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -664,16 +613,15 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .get, - server: server, - endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Adds a reaction to the given message in this room. The user must have read access in the room. @@ -681,11 +629,10 @@ public enum OpenGroupAPI { /// Reactions are short strings of 1-12 unicode codepoints, typically emoji (or character sequences to produce an emoji variant, /// such as 👨🏿‍🦰, which is composed of 4 unicode "characters" but usually renders as a single emoji "Man: Dark Skin Tone, Red Hair"). public static func preparedReactionAdd( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -696,26 +643,24 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .put, - server: server, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionAddResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes a reaction from a post this room. The user must have read access in the room. This only removes the user's own reaction /// but does not affect the reactions of other users. public static func preparedReactionDelete( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -726,27 +671,25 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionRemoveResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes all reactions of all users from a post in this room. The calling must have moderator permissions in the room. This endpoint /// can either remove a single reaction (e.g. remove all 🍆 reactions) by specifying it after the message id (following a /), or remove all /// reactions from the post by not including the / suffix of the URL. public static func preparedReactionDeleteAll( - _ db: Database, emoji: String, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. @@ -757,16 +700,15 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji) + endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji), + authMethod: authMethod ), responseType: ReactionRemoveAllResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Pinning @@ -782,107 +724,96 @@ public enum OpenGroupAPI { /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed public static func preparedPinMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomPinMessage(roomToken, id: id) + endpoint: .roomPinMessage(roomToken, id: id), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinMessage( - _ db: Database, id: Int64, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomUnpinMessage(roomToken, id: id) + endpoint: .roomUnpinMessage(roomToken, id: id), + authMethod: authMethod, ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes _all_ pinned messages from this room /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinAll( - _ db: Database, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, - endpoint: .roomUnpinAll(roomToken) + endpoint: .roomUnpinAll(roomToken), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Files public static func preparedUpload( - _ db: Database, data: Data, - to roomToken: String, - on server: String, + roomToken: String, fileName: String? = nil, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - let maybePublicKey: String? = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == server.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) - - guard let serverPublicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } + guard case .community(let server, let publicKey, _, _, _) = authMethod.info else { + throw NetworkError.invalidPreparedRequest + } return try Network.PreparedRequest( request: Request( endpoint: Endpoint.roomFile(roomToken), destination: .serverUpload( server: server, - x25519PublicKey: serverPublicKey, + x25519PublicKey: publicKey, fileName: fileName ), body: data ), responseType: FileUploadResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: Network.fileUploadTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } public static func downloadUrlString( @@ -894,36 +825,33 @@ public enum OpenGroupAPI { } public static func preparedDownload( - _ db: Database, url: URL, - from roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard let fileId: String = Attachment.fileId(for: url.absoluteString) else { throw NetworkError.invalidURL } - return try preparedDownload(db, fileId: fileId, from: roomToken, on: server, using: dependencies) + return try preparedDownload(fileId: fileId, roomToken: roomToken, authMethod: authMethod, using: dependencies) } public static func preparedDownload( - _ db: Database, fileId: String, - from roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .roomFileIndividual(roomToken, fileId) + endpoint: .roomFileIndividual(roomToken, fileId), + authMethod: authMethod ), responseType: Data.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: Network.fileDownloadTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Inbox/Outbox (Message Requests) @@ -932,137 +860,125 @@ public enum OpenGroupAPI { /// /// **Note:** `inbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInbox( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .inbox + endpoint: .inbox, + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** `inboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInboxSince( - _ db: Database, id: Int64, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .inboxSince(id: id) + endpoint: .inboxSince(id: id), + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Remove all message requests from inbox, this methrod will return the number of messages deleted public static func preparedClearInbox( - _ db: Database, - on server: String, requestTimeout: TimeInterval = Network.defaultTimeout, requestAndPathBuildTimeout: TimeInterval? = nil, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .delete, - server: server, - endpoint: .inbox + endpoint: .inbox, + authMethod: authMethod ), responseType: DeleteInboxResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver public static func preparedSend( - _ db: Database, ciphertext: Data, toInboxFor blindedSessionId: String, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), body: SendDirectMessageRequest( message: ciphertext - ) + ), + authMethod: authMethod ), responseType: SendDirectMessageResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Retrieves all of the user's sent DMs (up to limit) /// /// **Note:** `outbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutbox( - _ db: Database, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .outbox + endpoint: .outbox, + authMethod: authMethod ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** `outboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutboxSince( - _ db: Database, id: Int64, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest<[DirectMessage]?> { return try Network.PreparedRequest( request: Request( - db, - server: server, - endpoint: .outboxSince(id: id) + endpoint: .outboxSince(id: id), + authMethod: authMethod, ), responseType: [DirectMessage]?.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Users @@ -1099,30 +1015,28 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserBan( - _ db: Database, sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userBan(sessionId), body: UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), timeout: timeout - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Removes a user ban from specific rooms, or from the server globally @@ -1150,28 +1064,26 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserUnban( - _ db: Database, sessionId: String, from roomTokens: [String]?, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userUnban(sessionId), body: UserUnbanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// Appoints or removes a moderator or admin @@ -1226,13 +1138,12 @@ public enum OpenGroupAPI { /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserModeratorUpdate( - _ db: Database, sessionId: String, moderator: Bool? = nil, admin: Bool? = nil, visible: Bool, for roomTokens: [String]?, - on server: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { @@ -1241,9 +1152,7 @@ public enum OpenGroupAPI { return try Network.PreparedRequest( request: Request( - db, method: .post, - server: server, endpoint: Endpoint.userModerator(sessionId), body: UserModeratorRequest( rooms: roomTokens, @@ -1251,69 +1160,63 @@ public enum OpenGroupAPI { moderator: moderator, admin: admin, visible: visible - ) + ), + authMethod: authMethod ), responseType: NoResponse.self, - additionalSignatureData: AdditionalSigningData(server: server), + additionalSignatureData: AdditionalSigningData(authMethod), using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method public static func preparedUserBanAndDeleteAllMessages( - _ db: Database, sessionId: String, - in roomToken: String, - on server: String, + roomToken: String, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest> { return try OpenGroupAPI .preparedSequence( - db, - server: server, requests: [ preparedUserBan( - db, sessionId: sessionId, from: [roomToken], - on: server, + authMethod: authMethod, using: dependencies ), preparedMessagesDeleteAll( - db, sessionId: sessionId, - in: roomToken, - on: server, + roomToken: roomToken, + authMethod: authMethod, using: dependencies ) ], + authMethod: authMethod, using: dependencies ) - .signed(db, with: OpenGroupAPI.signRequest, using: dependencies) + .signed(with: OpenGroupAPI.signRequest, using: dependencies) } // MARK: - Authentication fileprivate static func signatureHeaders( - _ db: Database, url: URL, method: HTTPMethod, - server: String, - serverPublicKey: String, body: Data?, - forceBlinded: Bool, + authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { let path: String = url.path .appending(url.query.map { value in "?\(value)" }) let method: String = method.rawValue let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) - let serverPublicKeyData: Data = Data(hex: serverPublicKey) guard - !serverPublicKeyData.isEmpty, + case .community(_, let publicKey, _, _, _) = authMethod.info, + !publicKey.isEmpty, let nonce: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(16)), let timestampBytes: [UInt8] = "\(timestamp)".data(using: .ascii).map({ Array($0) }) else { throw OpenGroupAPIError.signingFailed } @@ -1333,7 +1236,7 @@ public enum OpenGroupAPI { /// `Method` /// `Path` /// `Body` is a Blake2b hash of the data (if there is a body) - let messageBytes: [UInt8] = serverPublicKeyData.bytes + let messageBytes: [UInt8] = Data(hex: publicKey).bytes .appending(contentsOf: nonce) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) @@ -1342,11 +1245,9 @@ public enum OpenGroupAPI { /// Sign the above message let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, messageBytes: messageBytes, - for: server, + authMethod: authMethod, fallbackSigningType: .unblinded, - forceBlinded: forceBlinded, using: dependencies ) @@ -1360,42 +1261,30 @@ public enum OpenGroupAPI { /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func sign( - _ db: Database, messageBytes: [UInt8], - for serverName: String, + authMethod: AuthenticationMethod, fallbackSigningType signingType: SessionId.Prefix, - forceBlinded: Bool = false, using dependencies: Dependencies ) throws -> (publicKey: String, signature: [UInt8]) { guard !dependencies[cache: .general].ed25519SecretKey.isEmpty, - let serverPublicKey: String = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == serverName.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) + !dependencies[cache: .general].ed25519Seed.isEmpty, + case .community(_, let publicKey, let hasCapabilities, let supportsBlinding, let forceBlinded) = authMethod.info else { throw OpenGroupAPIError.signingFailed } - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == serverName.lowercased()) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - // If we have no capabilities or if the server supports blinded keys then sign using the blinded key - if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { + if forceBlinded || !hasCapabilities || supportsBlinding { guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( .blinded15KeyPair( - serverPublicKey: serverPublicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( .signatureBlind15( message: messageBytes, - serverPublicKey: serverPublicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) @@ -1431,14 +1320,22 @@ public enum OpenGroupAPI { // Default to using the 'standard' key default: guard - let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db), + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ), + let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Pubkey: ed25519KeyPair.publicKey) + ), + let x25519SecretKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Seckey: ed25519KeyPair.secretKey) + ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( - .signatureXed25519(data: messageBytes, curve25519PrivateKey: userKeyPair.secretKey) + .signatureXed25519(data: messageBytes, curve25519PrivateKey: x25519SecretKey) ) else { throw OpenGroupAPIError.signingFailed } return ( - publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, + publicKey: SessionId(.standard, publicKey: x25519PublicKey).hexString, signature: signatureResult ) } @@ -1446,7 +1343,6 @@ public enum OpenGroupAPI { /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func signRequest( - _ db: Database, preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { @@ -1455,47 +1351,42 @@ public enum OpenGroupAPI { } return try preparedRequest.destination - .signed(db, data: signingData, body: preparedRequest.body, using: dependencies) + .signed(data: signingData, body: preparedRequest.body, using: dependencies) } } private extension OpenGroupAPI { struct AdditionalSigningData { - let server: String - let forceBlinded: Bool + let authMethod: AuthenticationMethod - init(server: String, forceBlinded: Bool = false) { - self.server = server - self.forceBlinded = forceBlinded + init(_ authMethod: AuthenticationMethod) { + self.authMethod = authMethod } } } private extension Network.Destination { - func signed(_ db: Database, data: OpenGroupAPI.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { + func signed(data: OpenGroupAPI.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { switch self { case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: throw NetworkError.unauthorised case .cached: return self - case .server(let info): return .server(info: try info.signed(db, data, body, using: dependencies)) + case .server(let info): return .server(info: try info.signed(data, body, using: dependencies)) case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.signed(db, data, body, using: dependencies), fileName: fileName) + return .serverUpload(info: try info.signed(data, body, using: dependencies), fileName: fileName) case .serverDownload(let info): - return .serverDownload(info: try info.signed(db, data, body, using: dependencies)) + return .serverDownload(info: try info.signed(data, body, using: dependencies)) } } } private extension Network.Destination.ServerInfo { - func signed(_ db: Database, _ data: OpenGroupAPI.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { + func signed(_ data: OpenGroupAPI.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { return updated(with: try OpenGroupAPI.signatureHeaders( - db, url: url, method: method, - server: data.server, - serverPublicKey: x25519PublicKey, body: body, - forceBlinded: data.forceBlinded, + authMethod: data.authMethod, using: dependencies )) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 1796b10053..ba2599013a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -236,16 +236,23 @@ public final class OpenGroupManager { return OpenGroupAPI.defaultServer }() - return dependencies[singleton: .storage] - .readPublisher { [dependencies] db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: roomToken, - on: targetServer, + return Result { + try OpenGroupAPI + .preparedCapabilitiesAndRoom( + roomToken: roomToken, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: roomToken, + server: server, + publicKey: publicKey, + capabilities: [] /// We won't have `capabilities` before the first request so just hard code + ) + ), using: dependencies ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } + .publisher + .flatMap { [dependencies] in $0.send(using: dependencies) } .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: Database, response: (info: ResponseInfoType, value: OpenGroupAPI.CapabilitiesAndRoomResponse)) -> Void in // Add the new open group to libSession try LibSession.add( @@ -560,6 +567,7 @@ public final class OpenGroupManager { try MessageDeduplication.insert( db, processedMessage: processedMessage, + ignoreDedupeFiles: false, using: dependencies ) @@ -709,6 +717,7 @@ public final class OpenGroupManager { try MessageDeduplication.insert( db, processedMessage: processedMessage, + ignoreDedupeFiles: false, using: dependencies ) diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift index 53e0010ae4..d5ab81cbe1 100644 --- a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -10,6 +10,7 @@ public enum OpenGroupAPIError: Error, CustomStringConvertible { case noPublicKey case invalidEmoji case invalidPoll + case blindedLookupMissingCommunityInfo public var description: String { switch self { @@ -18,6 +19,7 @@ public enum OpenGroupAPIError: Error, CustomStringConvertible { case .noPublicKey: return "Couldn't find server public key." case .invalidEmoji: return "The emoji is invalid." case .invalidPoll: return "Poller in invalid state." + case .blindedLookupMissingCommunityInfo: return "Blinded lookup missing community info." } } } diff --git a/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift index cf5ffd9093..6242a05447 100644 --- a/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/Types/Request+OpenGroupAPI.swift @@ -9,21 +9,16 @@ import SessionUtilitiesKit public extension Request where Endpoint == OpenGroupAPI.Endpoint { init( - _ db: Database, method: HTTPMethod = .get, - server: String, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], - body: T? = nil + body: T? = nil, + authMethod: AuthenticationMethod ) throws { - let maybePublicKey: String? = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == server.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) - - guard let publicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } + guard case .community(let server, let publicKey, _, _, _) = authMethod.info else { + throw CryptoError.signatureGenerationFailed + } self = try Request( endpoint: endpoint, diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift new file mode 100644 index 0000000000..de5de179f8 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -0,0 +1,228 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - AttachmentUploader + +public final class AttachmentUploader { + private enum Destination { + case fileServer + case community(LibSession.OpenGroupCapabilityInfo) + + var shouldEncrypt: Bool { + switch self { + case .fileServer: return true + case .community: return false + } + } + } + + public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] { + return attachments.compactMap { signalAttachment in + Attachment( + variant: (signalAttachment.isVoiceMessage ? + .voiceMessage : + .standard + ), + contentType: signalAttachment.mimeType, + dataSource: signalAttachment.dataSource, + sourceFilename: signalAttachment.sourceFilename, + caption: signalAttachment.captionText, + using: dependencies + ) + } + } + + public static func process( + _ db: Database, + attachments: [Attachment]?, + for interactionId: Int64? + ) throws { + guard + let attachments: [Attachment] = attachments, + let interactionId: Int64 = interactionId + else { return } + + try attachments + .enumerated() + .forEach { index, attachment in + let interactionAttachment: InteractionAttachment = InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) + } + } + + public static func preparedUpload( + attachment: Attachment, + logCategory cat: Log.Category?, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> { + typealias UploadInfo = ( + attachment: Attachment, + preparedRequest: Network.PreparedRequest, + encryptionKey: Data?, + digest: Data? + ) + typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) + + // Generate the correct upload info based on the state of the attachment + let destination: AttachmentUploader.Destination = { + switch authMethod { + case let auth as Authentication.community: return .community(auth.openGroupCapabilityInfo) + default: return .fileServer + } + }() + let uploadInfo: UploadInfo = try { + let endpoint: (any EndpointType) = { + switch destination { + case .fileServer: return Network.FileServer.Endpoint.file + case .community(let info): return OpenGroupAPI.Endpoint.roomFile(info.roomToken) + } + }() + + // This can occur if an AttachmentUploadJob was explicitly created for a message + // dependant on the attachment being uploaded (in this case the attachment has + // already been uploaded so just succeed) + if attachment.state == .uploaded, let fileId: String = Attachment.fileId(for: attachment.downloadUrl) { + return ( + attachment, + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId), + endpoint: endpoint, + using: dependencies + ), + attachment.encryptionKey, + attachment.digest + ) + } + + // If the attachment is a downloaded attachment, check if it came from + // the server and if so just succeed immediately (no use re-uploading + // an attachment that is already present on the server) - or if we want + // it to be encrypted and it's not then encrypt it + // + // Note: The most common cases for this will be for LinkPreviews or Quotes + if + attachment.state == .downloaded, + attachment.serverId != nil, + let fileId: String = Attachment.fileId(for: attachment.downloadUrl), + ( + !destination.shouldEncrypt || ( + attachment.encryptionKey != nil && + attachment.digest != nil + ) + ) + { + return ( + attachment, + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId), + endpoint: endpoint, + using: dependencies + ), + attachment.encryptionKey, + attachment.digest + ) + } + + // Get the raw attachment data + guard let rawData: Data = try? attachment.readDataFromFile(using: dependencies) else { + Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") + throw AttachmentError.noAttachment + } + + // Encrypt the attachment if needed + var finalData: Data = rawData + var encryptionKey: Data? + var digest: Data? + + if destination.shouldEncrypt { + guard + let result: EncryptionData = dependencies[singleton: .crypto].generate( + .encryptAttachment(plaintext: rawData) + ) + else { + Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") + throw AttachmentError.encryptionFailed + } + + finalData = result.ciphertext + encryptionKey = result.encryptionKey + digest = result.digest + } + + // Ensure the file size is smaller than our upload limit + Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.") + guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } + + // Generate the request + switch destination { + case .fileServer: + return ( + attachment, + try Network.preparedUpload(data: finalData, using: dependencies), + encryptionKey, + digest + ) + + case .community(let info): + return ( + attachment, + try OpenGroupAPI.preparedUpload( + data: finalData, + roomToken: info.roomToken, + authMethod: Authentication.community(info: info), + using: dependencies + ), + encryptionKey, + digest + ) + } + }() + + return uploadInfo.preparedRequest.map { _, response in + /// Generate the updated attachment info + /// + /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is + /// updated correctly + let updatedAttachment: Attachment = uploadInfo.attachment + .with( + serverId: response.id, + state: .uploaded, + creationTimestamp: ( + uploadInfo.attachment.creationTimestamp ?? + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ), + downloadUrl: { + switch (uploadInfo.attachment.downloadUrl, destination) { + case (.some(let downloadUrl), _): return downloadUrl + case (.none, .fileServer): + return Network.FileServer.downloadUrlString(for: response.id) + + case (.none, .community(let info)): + return OpenGroupAPI.downloadUrlString( + for: response.id, + server: info.server, + roomToken: info.roomToken + ) + } + }(), + encryptionKey: uploadInfo.encryptionKey, + digest: uploadInfo.digest, + using: dependencies + ) + + return (updatedAttachment, response.id) + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index ea7a467bb0..4a4062cfd4 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -14,6 +14,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case encryptionFailed case noUsername case attachmentsNotUploaded + case attachmentsInvalid case blindingFailed // Closed groups @@ -45,6 +46,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { 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 .attachmentsInvalid: return "Attachments Invalid (MessageSenderError.attachmentsInvalid)." case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." // Closed groups diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index a89bd0a968..488668a845 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -27,27 +27,39 @@ extension MessageReceiver { // Only support calls from contact threads guard threadVariant == .contact else { return } - switch message.kind { - case .preOffer: + switch (message.kind, message.state) { + case (.preOffer, _): try MessageReceiver.handleNewCallMessage( db, + threadId: threadId, + threadVariant: threadVariant, message: message, suppressNotifications: suppressNotifications, using: dependencies ) - case .offer: MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies) - case .answer: MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies) - case .provisionalAnswer: break // TODO: [CALLS] Implement + case (.offer, _): MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies) + case (.answer, _): MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies) + case (.provisionalAnswer, _): break // TODO: [CALLS] Implement - case let .iceCandidates(sdpMLineIndexes, sdpMids): + case (.iceCandidates(let sdpMLineIndexes, let sdpMids), _): dependencies[singleton: .callManager].handleICECandidates( message: message, sdpMLineIndexes: sdpMLineIndexes, sdpMids: sdpMids ) + + case (.endCall, .missed): + try MessageReceiver.handleIncomingCallOfferInBusyState( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + suppressNotifications: suppressNotifications, + using: dependencies + ) - case .endCall: MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies) + case (.endCall, _): MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies) } } @@ -55,6 +67,8 @@ extension MessageReceiver { private static func handleNewCallMessage( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, message: CallMessage, suppressNotifications: Bool, using dependencies: Dependencies @@ -70,17 +84,14 @@ extension MessageReceiver { guard dependencies[singleton: .appContext].isMainApp, let sender: String = message.sender, - (try? Contact - .filter(id: sender) - .select(.isApproved) - .asRequest(of: Bool.self) - .fetchOne(db)) - .defaulting(to: false) + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant) + }) else { return } guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { // Add missed call message for call offer messages from more than one minute Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -124,7 +135,7 @@ extension MessageReceiver { Log.info(.calls, "Microphone permission is \(AVAudioSession.sharedInstance().recordPermission)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -170,20 +181,38 @@ extension MessageReceiver { return } - // Ignore pre offer message after the same call instance has been generated - if let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall, currentCall.uuid == message.uuid { - Log.info(.calls, "Ignoring pre-offer message for call[\(currentCall.uuid)] instance because it is already active.") - return + /// If we are already on a call that is different from the current one then we are in a busy state + guard + dependencies[singleton: .callManager].currentCall == nil || + dependencies[singleton: .callManager].currentCall?.uuid == message.uuid + else { + return try handleIncomingCallOfferInBusyState( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + suppressNotifications: suppressNotifications, + using: dependencies + ) } + /// Insert the call info message for the message (this needs to happen whether it's a new call or an existing call since the PN + /// extension will no longer insert this itself) + let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + for: message, + using: dependencies + ) + + /// Ignore pre offer message after the same call instance has been generated guard dependencies[singleton: .callManager].currentCall == nil else { - try MessageReceiver.handleIncomingCallOfferInBusyState(db, message: message, using: dependencies) + Log.info(.calls, "Ignoring pre-offer message for call[\(message.uuid)] instance because it is already active.") return } - let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage(db, for: message, using: dependencies) - - // Handle UI + /// Handle UI for the new call dependencies[singleton: .callManager].showCallUIForCall( caller: sender, uuid: message.uuid, @@ -263,7 +292,10 @@ extension MessageReceiver { public static func handleIncomingCallOfferInBusyState( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, message: CallMessage, + suppressNotifications: Bool, using dependencies: Dependencies ) throws { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) @@ -271,16 +303,11 @@ extension MessageReceiver { guard let caller: String = message.sender, let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo), - !SessionThread.isMessageRequest( - db, - threadId: caller, - userSessionId: dependencies[cache: .general].sessionId - ), - let thread: SessionThread = try SessionThread.fetchOne(db, id: caller) + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: caller, threadVariant: threadVariant) + }) else { return } - Log.info(.calls, "Sending end call message because there is an ongoing call.") - let messageSentTimestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -288,16 +315,16 @@ extension MessageReceiver { _ = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, authorId: caller, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), timestampMs: messageSentTimestampMs, wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, timestampMs: messageSentTimestampMs, openGroupUrlInfo: nil ) @@ -307,35 +334,58 @@ extension MessageReceiver { using: dependencies ) .inserted(db) - - try MessageSender - .preparedSend( - db, - message: CallMessage( - uuid: message.uuid, - kind: .endCall, - sdps: [], - sentTimestampMs: nil // Explicitly nil as it's a separate message from above - ) - .with(try? thread.disappearingMessagesConfiguration - .fetchOne(db)? - .forcedWithDisappearAfterReadIfNeeded() - ), - to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), - namespace: try Message.Destination - .from(db, threadId: thread.id, threadVariant: thread.variant) - .defaultNamespace, - interactionId: nil, // Explicitly nil as it's a separate message from above - fileIds: [], + + /// If we are suppressing notifications then we are loading in messages that were cached by the extensions, in which case it's + /// an old message so we would have already sent the response (all we would have needed to do in this case was save the + /// `interaction` above to the database) + if !suppressNotifications { + Log.info(.calls, "Sending end call message because there is an ongoing call.") + + try sendIncomingCallOfferInBusyStateResponse( + threadId: threadId, + message: message, + disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration + .fetchOne(db, id: threadId), + authMethod: try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() + } + } + + public static func sendIncomingCallOfferInBusyStateResponse( + threadId: String, + message: CallMessage, + disappearingMessagesConfiguration: DisappearingMessagesConfiguration?, + authMethod: AuthenticationMethod, + onEvent: ((MessageSender.Event) -> Void)?, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try MessageSender.preparedSend( + message: CallMessage( + uuid: message.uuid, + kind: .endCall, + sdps: [], + sentTimestampMs: nil // Explicitly nil as it's a separate message from above + ) + .with(disappearingMessagesConfiguration?.forcedWithDisappearAfterReadIfNeeded()), + to: .contact(publicKey: threadId), + namespace: .default, + interactionId: nil, // Explicitly nil as it's a separate message from above + attachments: nil, + authMethod: authMethod, + onEvent: onEvent, + using: dependencies + ) } @discardableResult public static func insertCallInfoMessage( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, for message: CallMessage, state: CallMessage.MessageInfo.State? = nil, using dependencies: Dependencies @@ -345,17 +395,14 @@ extension MessageReceiver { .filter(Interaction.Columns.variant == Interaction.Variant.infoCall) .filter(Interaction.Columns.messageUuid == message.uuid) .isEmpty(db) - ).defaulting(to: false) + ).defaulting(to: false) else { throw MessageReceiverError.duplicatedCall } guard let sender: String = message.sender, - !SessionThread.isMessageRequest( - db, - threadId: sender, - userSessionId: dependencies[cache: .general].sessionId - ), - let thread: SessionThread = try SessionThread.fetchOne(db, id: sender) + dependencies.mutate(cache: .libSession, { cache in + !cache.isMessageRequest(threadId: sender, threadVariant: threadVariant) + }) else { return nil } let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -379,16 +426,16 @@ extension MessageReceiver { return try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, authorId: sender, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), timestampMs: timestampMs, wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( - threadId: thread.id, - threadVariant: thread.variant, + threadId: threadId, + threadVariant: threadVariant, timestampMs: timestampMs, openGroupUrlInfo: nil ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index f9217e2f3a..cb678b9d82 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -594,7 +594,7 @@ extension MessageSender { maybeSupplementalKeyRequest = try SnodeAPI.preparedSendMessage( message: SnodeMessage( recipient: sessionId.hexString, - data: supplementData.base64EncodedString(), + data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, timestampMs: UInt64(changeTimestampMs) ), @@ -857,7 +857,7 @@ extension MessageSender { maybeSupplementalKeyRequest = try SnodeAPI.preparedSendMessage( message: SnodeMessage( recipient: sessionId.hexString, - data: supplementData.base64EncodedString(), + data: supplementData, ttl: ConfigDump.Variant.groupKeys.ttl, timestampMs: UInt64(changeTimestampMs) ), diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 330bb843b7..052b8b230e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -90,31 +90,339 @@ extension MessageSender { canStartJob: true ) } +} + +// MARK: - Success & Failure Handling - // MARK: - Non-Durable +extension MessageSender { + public static func standardEventHandling(using dependencies: Dependencies) -> ((Event) -> Void) { + return { event in + dependencies[singleton: .storage].write { db in + switch event { + case .willSend(let message, let destination, let interactionId): + handleMessageWillSend( + db, + message: message, + destination: destination, + interactionId: interactionId + ) + + case .success(let message, let destination, let interactionId, let serverTimestampMs, let serverExpirationMs): + try handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId, + serverTimestampMs: serverTimestampMs, + serverExpirationTimestampMs: serverExpirationMs, + using: dependencies + ) + + case .failure(let message, let destination, let interactionId, let error): + handleFailedMessageSend( + db, + message: message, + destination: destination, + error: error, + interactionId: interactionId, + using: dependencies + ) + } + } + } + } - public static func preparedSend( + internal static func handleMessageWillSend( _ db: Database, - interaction: Interaction, - fileIds: [String], - threadId: String, - threadVariant: SessionThread.Variant, + message: Message, + 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) + switch destination { + case .syncMessage: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.failedToSync) + .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.syncing)) + + default: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.failed) + .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.sending)) + } + } + + private static func handleSuccessfulMessageSend( + _ db: Database, + message: Message, + to destination: Message.Destination, + interactionId: Int64?, + serverTimestampMs: Int64? = nil, + serverExpirationTimestampMs: Int64? = nil, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - // Only 'VisibleMessage' types can be sent via this method - guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } - guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + ) throws { + // If the message was a reaction then we want to update the reaction instead of the original + // interaction (which the 'interactionId' is pointing to + if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction { + try Reaction + .filter(Reaction.Columns.interactionId == interactionId) + .filter(Reaction.Columns.authorId == reaction.publicKey) + .filter(Reaction.Columns.emoji == reaction.emoji) + .updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash)) + } + else { + // Otherwise we do want to try and update the referenced interaction + let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId) + + // 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 + switch (message.isSelfSendValid, destination) { + case (false, .syncMessage): + try interaction.with(state: .sent).update(db) + + 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) } + ), + openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, + state: .sent + ).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[singleton: .jobRunner].upsert( + db, + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: Double(interaction.timestampMs), + using: dependencies + ), + canStartJob: true + ) + + 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[singleton: .jobRunner].add( + db, + job: Job( + variant: .expirationUpdate, + behaviour: .runOnce, + threadId: interaction.threadId, + details: ExpirationUpdateJob.Details( + serverHashes: [serverHash], + expirationTimestampMs: expirationTimestampMs + ) + ), + canStartJob: true + ) + } + } + } + } + } + + // Extract the threadId from the message + let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) + + // Insert a `MessageDeduplication` record so we don't handle this message when it's received + // in the next poll + try MessageDeduplication.insert( + db, + threadId: threadId, + threadVariant: destination.threadVariant, + uniqueIdentifier: { + if let serverHash: String = message.serverHash { return serverHash } + if let openGroupServerMessageId: UInt64 = message.openGroupServerMessageId { + return "\(openGroupServerMessageId)" + } + + let variantString: String = Message.Variant(from: message) + .map { "\($0)" } + .defaulting(to: "Unknown Variant") // stringlint:ignore + Log.warn(.messageSender, "Unable to store deduplication unique identifier for outgoing message of type: \(variantString).") + return nil + }(), + message: message, + serverExpirationTimestamp: serverExpirationTimestampMs.map { (TimeInterval($0) / 1000) }, + ignoreDedupeFiles: false, + using: dependencies + ) - return try MessageSender.preparedSend( + // Sync the message if needed + scheduleSyncMessageIfNeeded( db, - message: VisibleMessage.from(db, interaction: interaction), - to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - namespace: try Message.Destination - .from(db, threadId: threadId, threadVariant: threadVariant) - .defaultNamespace, + message: message, + destination: destination, + threadId: threadId, interactionId: interactionId, - fileIds: fileIds, using: dependencies ) } + + @discardableResult internal static func handleFailedMessageSend( + _ db: Database, + message: Message, + destination: Message.Destination?, + error: MessageSenderError, + interactionId: Int64?, + using dependencies: Dependencies + ) -> Error { + // Log a message for any 'other' errors + switch error { + case .other(let cat, let description, let error): + Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") + default: break + } + + // Only 'VisibleMessage' messages can show a status so don't bother updating + // the other cases (if the VisibleMessage was a reaction then we also don't + // want to do anything as the `interactionId` points to the original message + // which has it's own status) + switch message { + case let message as VisibleMessage where message.reaction != nil: return error + case is VisibleMessage: break + default: return error + } + + /// Check if we need to mark any "sending" recipients as "failed" and update their errors + switch destination { + case .syncMessage: + _ = try? Interaction + .filter(id: interactionId) + .filter( + Interaction.Columns.state == Interaction.State.syncing || + Interaction.Columns.state == Interaction.State.sent + ) + .updateAll( + db, + Interaction.Columns.state.set(to: Interaction.State.failedToSync), + Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + ) + + default: + _ = try? Interaction + .filter(id: interactionId) + .filter(Interaction.Columns.state == Interaction.State.sending) + .updateAll( + db, + Interaction.Columns.state.set(to: Interaction.State.failed), + Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + ) + } + + return error + } + + private static func interaction(_ db: Database, for message: Message, interactionId: Int64?) throws -> Interaction? { + if let interactionId: Int64 = interactionId { + return try Interaction.fetchOne(db, id: interactionId) + } + + if let sentTimestampMs: Double = message.sentTimestampMs.map({ Double($0) }) { + return try Interaction + .filter(Interaction.Columns.timestampMs == sentTimestampMs) + .fetchOne(db) + } + + return nil + } + + private static func scheduleSyncMessageIfNeeded( + _ db: Database, + message: Message, + destination: Message.Destination, + threadId: String?, + interactionId: Int64?, + using dependencies: Dependencies + ) { + // Sync the message if it's not a sync message, wasn't already sent to the current user and + // it's a message type which should be synced + let userSessionId = dependencies[cache: .general].sessionId + + if + case .contact(let publicKey) = destination, + publicKey != userSessionId.hexString, + Message.shouldSync(message: message) + { + if let message = message as? VisibleMessage { message.syncTarget = publicKey } + if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } + + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + threadId: threadId, + interactionId: interactionId, + details: MessageSendJob.Details( + destination: .syncMessage(originalRecipientPublicKey: publicKey), + message: message + ) + ), + canStartJob: true + ) + } + } +} + +// MARK: - Database Type Conversion + +public extension VisibleMessage { + static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { + let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) + + let visibleMessage: VisibleMessage = VisibleMessage( + sender: interaction.authorId, + sentTimestampMs: UInt64(interaction.timestampMs), + syncTarget: nil, + text: interaction.body, + attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) + .map { $0.id }, + quote: (try? interaction.quote.fetchOne(db)) + .map { VMQuote.from(quote: $0) }, + linkPreview: linkPreview + .map { linkPreview in + guard linkPreview.variant == .standard else { return nil } + + return VMLinkPreview.from(linkPreview: linkPreview) + }, + profile: nil, // Don't attach the profile to avoid sending a legacy version (set in MessageSender) + openGroupInvitation: linkPreview.map { linkPreview in + guard linkPreview.variant == .openGroupInvitation else { return nil } + + return VMOpenGroupInvitation.from(linkPreview: linkPreview) + }, + reaction: nil // Reactions are custom messages sent separately + ) + .with( + expiresInSeconds: interaction.expiresInSeconds, + expiresStartedAtMs: interaction.expiresStartedAtMs + ) + + visibleMessage.expiresInSeconds = interaction.expiresInSeconds + visibleMessage.expiresStartedAtMs = interaction.expiresStartedAtMs + + return visibleMessage + } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 9ea8a46c47..3d345c1fdf 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -3,8 +3,6 @@ // stringlint:disable import Foundation -import Combine -import GRDB import SessionSnodeKit import SessionUtilitiesKit @@ -17,19 +15,26 @@ public extension Log.Category { // MARK: - MessageSender public final class MessageSender { + private typealias SendResponse = (message: Message, serverTimestampMs: Int64?, serverExpirationMs: Int64?) + public enum Event { + case willSend(Message, Message.Destination, interactionId: Int64?) + case success(Message, Message.Destination, interactionId: Int64?, serverTimestampMs: Int64?, serverExpirationMs: Int64?) + case failure(Message, Message.Destination, interactionId: Int64?, error: MessageSenderError) + } + // MARK: - Message Preparation public static func preparedSend( - _ db: Database, message: Message, to destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - fileIds: [String], + attachments: [(attachment: Attachment, fileId: String)]?, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // Common logic for all destinations - let userSessionId: SessionId = dependencies[cache: .general].sessionId let messageSendTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let updatedMessage: Message = message @@ -41,83 +46,102 @@ public final class MessageSender { updatedMessage.sigTimestampMs = updatedMessage.sentTimestampMs do { + let preparedRequest: Network.PreparedRequest + switch destination { case .contact, .syncMessage, .closedGroup: - return try preparedSendToSnodeDestination( - db, + preparedRequest = try preparedSendToSnodeDestination( message: updatedMessage, to: destination, namespace: namespace, interactionId: interactionId, - fileIds: fileIds, - userSessionId: userSessionId, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) - .map { _, _ in () } case .openGroup: - return try preparedSendToOpenGroupDestination( - db, + preparedRequest = try preparedSendToOpenGroupDestination( message: updatedMessage, to: destination, interactionId: interactionId, - fileIds: fileIds, - userSessionId: userSessionId, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) case .openGroupInbox: - return try preparedSendToOpenGroupInboxDestination( - db, + preparedRequest = try preparedSendToOpenGroupInboxDestination( message: message, to: destination, interactionId: interactionId, - fileIds: fileIds, - userSessionId: userSessionId, + attachments: attachments, messageSendTimestampMs: messageSendTimestampMs, + authMethod: authMethod, + onEvent: onEvent, using: dependencies ) } + + return preparedRequest + .handleEvents( + receiveOutput: { _, response in + onEvent?(.success( + response.message, + destination, + interactionId: interactionId, + serverTimestampMs: response.serverTimestampMs, + serverExpirationMs: response.serverExpirationMs + )) + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + onEvent?(.failure( + message, + destination, + interactionId: interactionId, + error: .other(nil, "Couldn't send message", error) + )) + } + } + ) + .map { _, response in response.message } } catch let error as MessageSenderError { - throw MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: error, - interactionId: interactionId, - using: dependencies - ) + onEvent?(.failure(message, destination, interactionId: interactionId, error: error)) + throw error } } - internal static func preparedSendToSnodeDestination( - _ db: Database, + private static func preparedSendToSnodeDestination( message: Message, to destination: Message.Destination, namespace: SnodeAPI.Namespace?, interactionId: Int64?, - fileIds: [String], - userSessionId: SessionId, + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { guard let namespace: SnodeAPI.Namespace = namespace else { throw MessageSenderError.invalidMessage } /// Set the sender/recipient info (needed to be valid) /// /// **Note:** The `sentTimestamp` will differ from the `messageSendTimestampMs` as it's the time the user originally /// sent the message whereas the `messageSendTimestamp` is the time it will be uploaded to the swarm + let userSessionId: SessionId = dependencies[cache: .general].sessionId let sentTimestampMs: UInt64 = (message.sentTimestampMs ?? UInt64(messageSendTimestampMs)) message.sender = userSessionId.hexString message.sentTimestampMs = sentTimestampMs message.sigTimestampMs = sentTimestampMs - // Ensure the message is valid - try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds, using: dependencies) - // 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 switch (destination, message as? MessageWithProfile) { @@ -135,104 +159,7 @@ public final class MessageSender { } } - // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - // Convert and prepare the data for sending - let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) - let plaintext: Data = try { - switch namespace { - case .revokedRetrievableGroupMessages: - return try BencodeEncoder(using: dependencies).encode(message) - - default: - guard let proto = message.toProto(db, threadId: threadId) else { - throw MessageSenderError.protoConversionFailed - } - - return try Result(proto.serializedData()) - .map { serialisedData -> Data in - switch destination { - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: - return serialisedData - - default: return serialisedData.paddedMessageBody() - } - } - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - } - }() - let base64EncodedData: String = try { - switch (destination, namespace) { - // Updated group messages should be wrapped _before_ encrypting - case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - let messageData: Data = try Result( - MessageWrapper.wrap( - type: .closedGroupMessage, - timestampMs: sentTimestampMs, - content: plaintext, - wrapInWebSocketMessage: false - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextForGroupMessage( - groupSessionId: SessionId(.group, hex: groupId), - message: Array(messageData) - ) - ) - return ciphertext.base64EncodedString() - - // revokedRetrievableGroupMessages should be sent in plaintext (their content has custom encryption) - case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - return plaintext.base64EncodedString() - - // Config messages should be sent directly rather than via this method - case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: - throw MessageSenderError.invalidConfigMessageHandling - - // Standard one-to-one messages and legacy groups (which used a `05` prefix) - case (.contact(let publicKey), .default), (.syncMessage(let publicKey), _), (.closedGroup(let publicKey), _): - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextWithSessionProtocol( - plaintext: plaintext, - destination: destination - ) - ) - - return try Result( - try MessageWrapper.wrap( - type: try { - switch destination { - case .contact, .syncMessage: return .sessionMessage - case .closedGroup: return .closedGroupMessage - default: throw MessageSenderError.invalidMessage - } - }(), - timestampMs: sentTimestampMs, - senderPublicKey: { - switch destination { - case .closedGroup: return publicKey // Needed for Android - default: return "" // Empty for all other cases - } - }(), - content: ciphertext - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - .base64EncodedString() - - // Config messages should be sent directly rather than via this method - case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling - case (.openGroup, _), (.openGroupInbox, _): preconditionFailure() - } - }() - - // Send the result let swarmPublicKey: String = { switch destination { case .contact(let publicKey): return publicKey @@ -243,100 +170,71 @@ public final class MessageSender { }() let snodeMessage = SnodeMessage( recipient: swarmPublicKey, - data: base64EncodedData, + data: try MessageSender.encodeMessageForSending( + namespace: namespace, + destination: destination, + message: message, + attachments: attachments, + authMethod: authMethod, + using: dependencies + ), ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies), timestampMs: UInt64(messageSendTimestampMs) ) + // Perform any pre-send actions + onEvent?(.willSend(message, destination, interactionId: interactionId)) + return try SnodeAPI .preparedSendMessage( message: snodeMessage, in: namespace, - authMethod: try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies), + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let updatedMessage: Message = message - updatedMessage.serverHash = response.hash - - // Save the updated message info and send a PN if needed - dependencies[singleton: .storage].write { db in - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - serverExpirationTimestamp: ( - Int64(floor(TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) / 1000)) - ), - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) + .map { _, response in + let expirationTimestampMs: Int64 = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) + let updatedMessage: Message = message + updatedMessage.serverHash = response.hash + + return (updatedMessage, nil, expirationTimestampMs) + } } private static func preparedSendToOpenGroupDestination( - _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - fileIds: [String], - userSessionId: SessionId, + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // Note: It's possible to send a message and then delete the open group you sent the message to // which would go into this case, so rather than handling it as an invalid state we just want to // error in a non-retryable way guard let message: VisibleMessage = message as? VisibleMessage, - case .openGroup(let roomToken, let server, let whisperTo, let whisperMods) = destination, - let openGroup: OpenGroup = try? OpenGroup.fetchOne( - db, - id: OpenGroup.idFor(roomToken: roomToken, server: server) - ), + case .community(let server, let publicKey, let hasCapabilities, let supportsBlinding, _) = authMethod.info, + case .openGroup(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, + server == destinationServer, let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) else { throw MessageSenderError.invalidMessage } // Set the sender/recipient info (needed to be valid) - let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = try { - let capabilities: [Capability.Variant] = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchAll(db)) - .defaulting(to: []) - // If the server doesn't support blinding then go with an unblinded id - guard capabilities.isEmpty || capabilities.contains(.blind) else { + guard !hasCapabilities || supportsBlinding else { return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString } guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( .blinded15KeyPair( - serverPublicKey: openGroup.publicKey, + serverPublicKey: publicKey, ed25519SecretKey: userEdKeyPair.secretKey ) ) @@ -344,11 +242,6 @@ public final class MessageSender { return SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString }() - - // Ensure the message is valid - try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds, using: dependencies) - - // Attach the user's profile message.profile = dependencies .mutate(cache: .libSession) { cache in cache.profile(contactId: userSessionId.hexString).map { @@ -366,83 +259,54 @@ public final class MessageSender { guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername } - // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - - // Convert it to protobuf - guard let proto = message.toProto(db, threadId: threadId) else { - throw MessageSenderError.protoConversionFailed - } + let plaintext: Data = try MessageSender.encodeMessageForSending( + namespace: .default, + destination: destination, + message: message, + attachments: attachments, + authMethod: authMethod, + using: dependencies + ) - // Serialize the protobuf - let plaintext: Data = try Result(proto.serializedData().paddedMessageBody()) - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() + // Perform any pre-send actions + onEvent?(.willSend(message, destination, interactionId: interactionId)) return try OpenGroupAPI .preparedSend( - db, plaintext: plaintext, - to: roomToken, - on: server, + roomToken: roomToken, whisperTo: whisperTo, whisperMods: whisperMods, - fileIds: fileIds, + fileIds: attachments?.map { $0.fileId }, + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let updatedMessage: Message = message - updatedMessage.openGroupServerMessageId = UInt64(response.id) - - dependencies[singleton: .storage].write { db in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - serverTimestampMs: Int64(floor(response.posted * 1000)), - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) - .map { _, _ in () } + .map { _, response in + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(response.id) + updatedMessage.sentTimestampMs = UInt64(floor(response.posted * 1000)) + + return (updatedMessage, Int64(floor(response.posted * 1000)), nil) + } } private static func preparedSendToOpenGroupInboxDestination( - _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - fileIds: [String], - userSessionId: SessionId, + attachments: [(attachment: Attachment, fileId: String)]?, messageSendTimestampMs: Int64, + authMethod: AuthenticationMethod, + onEvent: ((Event) -> Void)?, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { // The `openGroupInbox` destination does not support attachments guard - fileIds.isEmpty, - case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination + (attachments ?? []).isEmpty, + case .openGroupInbox(_, _, let recipientBlindedPublicKey) = destination else { throw MessageSenderError.invalidMessage } + let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = userSessionId.hexString // Attach the user's profile if needed @@ -460,378 +324,163 @@ public final class MessageSender { default: break } + let ciphertext: Data = try MessageSender.encodeMessageForSending( + namespace: .default, + destination: destination, + message: message, + attachments: nil, + authMethod: authMethod, + using: dependencies + ) // Perform any pre-send actions - handleMessageWillSend(db, message: message, destination: destination, interactionId: interactionId) - - // Convert it to protobuf - guard let proto = message.toProto(db, threadId: recipientBlindedPublicKey) else { - throw MessageSenderError.protoConversionFailed - } - - // Serialize the protobuf - let plaintext: Data = try Result(proto.serializedData().paddedMessageBody()) - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - - // Encrypt the serialized protobuf - let ciphertext: Data = try dependencies[singleton: .crypto].generateResult( - .ciphertextWithSessionBlindingProtocol( - plaintext: plaintext, - recipientBlindedId: recipientBlindedPublicKey, - serverPublicKey: openGroupPublicKey - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } - .successOrThrow() + onEvent?(.willSend(message, destination, interactionId: interactionId)) return try OpenGroupAPI .preparedSend( - db, ciphertext: ciphertext, toInboxFor: recipientBlindedPublicKey, - on: server, + authMethod: authMethod, using: dependencies ) - .handleEvents( - receiveOutput: { _, response in - let updatedMessage: Message = message - updatedMessage.openGroupServerMessageId = UInt64(response.id) - - dependencies[singleton: .storage].write { db in - // The `posted` value is in seconds but we sent it in ms so need that for de-duping - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: interactionId, - serverTimestampMs: Int64(floor(response.posted * 1000)), - serverExpirationTimestamp: Int64(floor(response.expires)), - using: dependencies - ) - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - dependencies[singleton: .storage].read { db in - MessageSender.handleFailedMessageSend( - db, - message: message, - destination: destination, - error: .other(nil, "Couldn't send message", error), - interactionId: interactionId, - using: dependencies - ) - } - } - } - ) - .map { _, _ in () } - } - - // MARK: - Success & Failure Handling - - private static func ensureValidMessage( - _ message: Message, - destination: Message.Destination, - fileIds: [String], - using dependencies: Dependencies - ) throws { - /// Check the message itself is valid - guard message.isValid(isSending: true) else { throw MessageSenderError.invalidMessage } - - /// We now allow the creation of message data without validating it's attachments have finished uploading first, this is here to - /// ensure we don't send a message which should have uploaded files - /// - /// If you see this error then you need to upload the associated attachments prior to sending the message - if let visibleMessage: VisibleMessage = message as? VisibleMessage { - let expectedAttachmentUploadCount: Int = ( - visibleMessage.attachmentIds.count + - (visibleMessage.linkPreview?.attachmentId != nil ? 1 : 0) + - (visibleMessage.quote?.attachmentId != nil ? 1 : 0) - ) - - guard expectedAttachmentUploadCount == fileIds.count else { - throw MessageSenderError.attachmentsNotUploaded + .map { _, response in + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(response.id) + updatedMessage.sentTimestampMs = UInt64(floor(response.posted * 1000)) + + return (updatedMessage, Int64(floor(response.posted * 1000)), Int64(floor(response.expires * 1000))) } - } } - public static func handleMessageWillSend( - _ db: Database, - message: Message, - 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) - switch destination { - case .syncMessage: - _ = try? Interaction - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.failedToSync) - .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.syncing)) - - default: - _ = try? Interaction - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.failed) - .updateAll(db, Interaction.Columns.state.set(to: Interaction.State.sending)) - } - } + // MARK: - Message Wrapping - private static func handleSuccessfulMessageSend( - _ db: Database, - message: Message, - to destination: Message.Destination, - interactionId: Int64?, - serverTimestampMs: Int64? = nil, - serverExpirationTimestamp: Int64? = nil, - using dependencies: Dependencies - ) throws { - // If the message was a reaction then we want to update the reaction instead of the original - // interaction (which the 'interactionId' is pointing to - if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction { - try Reaction - .filter(Reaction.Columns.interactionId == interactionId) - .filter(Reaction.Columns.authorId == reaction.publicKey) - .filter(Reaction.Columns.emoji == reaction.emoji) - .updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash)) - } - else { - // Otherwise we do want to try and update the referenced interaction - let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId) - - // 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 - switch (message.isSelfSendValid, destination) { - case (false, .syncMessage): - try interaction.with(state: .sent).update(db) - - 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) } - ), - openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }, - state: .sent - ).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[singleton: .jobRunner].upsert( - db, - job: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interaction: interaction, - startedAtMs: Double(interaction.timestampMs), - using: dependencies - ), - canStartJob: true - ) - - 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[singleton: .jobRunner].add( - db, - job: Job( - variant: .expirationUpdate, - behaviour: .runOnce, - threadId: interaction.threadId, - details: ExpirationUpdateJob.Details( - serverHashes: [serverHash], - expirationTimestampMs: expirationTimestampMs - ) - ), - canStartJob: true - ) - } - } - } - } - } - - // Extract the threadId from the message - let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) - - // Insert a `MessageDeduplication` record so we don't handle this message when it's received - // in the next poll - try MessageDeduplication.insert( - db, - threadId: threadId, - threadVariant: destination.threadVariant, - uniqueIdentifier: { - if let serverHash: String = message.serverHash { return serverHash } - if let openGroupServerMessageId: UInt64 = message.openGroupServerMessageId { - return "\(openGroupServerMessageId)" - } - - let variantString: String = Message.Variant(from: message) - .map { "\($0)" } - .defaulting(to: "Unknown Variant") - Log.warn(.messageSender, "Unable to store deduplication unique identifier for outgoing message of type: \(variantString).") - return nil - }(), - message: message, - serverExpirationTimestamp: serverExpirationTimestamp, - using: dependencies - ) - - // Sync the message if needed - scheduleSyncMessageIfNeeded( - db, - message: message, - destination: destination, - threadId: threadId, - interactionId: interactionId, - using: dependencies - ) - } - - @discardableResult internal static func handleFailedMessageSend( - _ db: Database, + public static func encodeMessageForSending( + namespace: SnodeAPI.Namespace, + destination: Message.Destination, message: Message, - destination: Message.Destination?, - error: MessageSenderError, - interactionId: Int64?, + attachments: [(attachment: Attachment, fileId: String)]?, + authMethod: AuthenticationMethod, using dependencies: Dependencies - ) -> Error { - // Log a message for any 'other' errors - switch error { - case .other(let cat, let description, let error): - Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") - default: break - } - - // Only 'VisibleMessage' messages can show a status so don't bother updating - // the other cases (if the VisibleMessage was a reaction then we also don't - // want to do anything as the `interactionId` points to the original message - // which has it's own status) - switch message { - case let message as VisibleMessage where message.reaction != nil: return error - case is VisibleMessage: break - default: return error - } + ) throws -> Data { + /// Check the message itself is valid + guard + message.isValid(isSending: true), + let sentTimestampMs: UInt64 = message.sentTimestampMs + else { throw MessageSenderError.invalidMessage } - // Check if we need to mark any "sending" recipients as "failed" - // - // 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? { - switch destination { - case .syncMessage: - return Interaction - .select(Column.rowID) - .filter(id: interactionId) - .filter( - Interaction.Columns.state == Interaction.State.syncing || - Interaction.Columns.state == Interaction.State.sent - ) + let plaintext: Data = try { + switch (namespace, destination) { + case (.revokedRetrievableGroupMessages, _): + return try BencodeEncoder(using: dependencies).encode(message) + + case (_, .openGroup), (_, .openGroupInbox): + guard + let proto: SNProtoContent = try message.toProto()? + .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) + else { throw MessageSenderError.protoConversionFailed } + + return try Result(proto.serializedData().paddedMessageBody()) + .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } + .successOrThrow() default: - return Interaction - .select(Column.rowID) - .filter(id: interactionId) - .filter(Interaction.Columns.state == Interaction.State.sending) + guard + let proto: SNProtoContent = try message.toProto()? + .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) + else { throw MessageSenderError.protoConversionFailed } + + return try Result(proto.serializedData()) + .map { serialisedData -> Data in + switch destination { + case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + return serialisedData + + default: return serialisedData.paddedMessageBody() + } + } + .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } + .successOrThrow() } }() - .asRequest(of: Int64.self) - .fetchAll(db)) - .defaulting(to: []) - - guard !rowIds.isEmpty else { return error } - /// If we have affected rows then we should update them with the latest error text - /// - /// **Note:** We `writeAsync` here as performing a syncronous `write` results in a reentrancy assertion - dependencies[singleton: .storage].writeAsync { db in - let targetState: Interaction.State - switch destination { - case .syncMessage: targetState = .failedToSync - default: targetState = .failed - } - - _ = try? Interaction - .filter(rowIds.contains(Column.rowID)) - .updateAll( - db, - Interaction.Columns.state.set(to: targetState), - Interaction.Columns.mostRecentFailureText.set(to: "\(error)") + switch (destination, namespace) { + /// Updated group messages should be wrapped _before_ encrypting + case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: + let messageData: Data = try Result( + MessageWrapper.wrap( + type: .closedGroupMessage, + timestampMs: sentTimestampMs, + content: plaintext, + wrapInWebSocketMessage: false + ) ) - } - - return error - } - - // MARK: - Convenience - - private static func interaction(_ db: Database, for message: Message, interactionId: Int64?) throws -> Interaction? { - if let interactionId: Int64 = interactionId { - return try Interaction.fetchOne(db, id: interactionId) - } - - if let sentTimestampMs: Double = message.sentTimestampMs.map({ Double($0) }) { - return try Interaction - .filter(Interaction.Columns.timestampMs == sentTimestampMs) - .fetchOne(db) - } - - return nil - } - - public static func scheduleSyncMessageIfNeeded( - _ db: Database, - message: Message, - destination: Message.Destination, - threadId: String?, - interactionId: Int64?, - using dependencies: Dependencies - ) { - // Sync the message if it's not a sync message, wasn't already sent to the current user and - // it's a message type which should be synced - let userSessionId = dependencies[cache: .general].sessionId - - if - case .contact(let publicKey) = destination, - publicKey != userSessionId.hexString, - Message.shouldSync(message: message) - { - if let message = message as? VisibleMessage { message.syncTarget = publicKey } - if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } + .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } + .successOrThrow() + + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .ciphertextForGroupMessage( + groupSessionId: SessionId(.group, hex: groupId), + message: Array(messageData) + ) + ) + return ciphertext + + /// `revokedRetrievableGroupMessages` should be sent in plaintext (their content has custom encryption) + case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: + return plaintext + + // Standard one-to-one messages and legacy groups (which used a `05` prefix) + case (.contact, .default), (.syncMessage, _), (.closedGroup, _): + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: plaintext, + destination: destination + ) + ) + + return try Result( + try MessageWrapper.wrap( + type: try { + switch destination { + case .contact, .syncMessage: return .sessionMessage + case .closedGroup: return .closedGroupMessage + default: throw MessageSenderError.invalidMessage + } + }(), + timestampMs: sentTimestampMs, + senderPublicKey: { + switch destination { + case .closedGroup: return try authMethod.swarmPublicKey // Needed for Android + default: return "" // Empty for all other cases + } + }(), + content: ciphertext + ) + ) + .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } + .successOrThrow() - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .messageSend, - threadId: threadId, - interactionId: interactionId, - details: MessageSendJob.Details( - destination: .syncMessage(originalRecipientPublicKey: publicKey), - message: message + /// Community messages should be sent in plaintext + case (.openGroup, _): return plaintext + + /// Blinded community messages have their own special encryption + case (.openGroupInbox(_, let serverPublicKey, let recipientBlindedPublicKey), _): + return try dependencies[singleton: .crypto].generateResult( + .ciphertextWithSessionBlindingProtocol( + plaintext: plaintext, + recipientBlindedId: recipientBlindedPublicKey, + serverPublicKey: serverPublicKey ) - ), - canStartJob: true - ) + ) + .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } + .successOrThrow() + + /// Config messages should be sent directly rather than via this method + case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: + throw MessageSenderError.invalidConfigMessageHandling + + /// Config messages should be sent directly rather than via this method + case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling } } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift index b51fc94ad8..9e7d30447b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift @@ -20,7 +20,9 @@ extension PushNotificationAPI { /// The signature unix timestamp (seconds, not ms) internal let timestamp: Int64 - var verificationBytes: [UInt8] { preconditionFailure("abstract class - override in subclass") } + var verificationBytes: [UInt8] { + get throws { preconditionFailure("abstract class - override in subclass") } + } // MARK: - Initialization @@ -39,7 +41,7 @@ extension PushNotificationAPI { // Generate the signature for the request for encoding let signature: Authentication.Signature = try authMethod.generateSignature( - with: verificationBytes, + with: try verificationBytes, using: try encoder.dependencies ?? { throw DependenciesError.missingDependencies }() ) try container.encode(timestamp, forKey: .timestamp) @@ -54,6 +56,8 @@ extension PushNotificationAPI { case .groupMember(let sessionId, _): try container.encode(sessionId.hexString, forKey: .pubkey) + + case .community: throw CryptoError.signatureGenerationFailed } switch signature { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift index 369cdcfba9..406808c9cc 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -47,12 +47,9 @@ extension PushNotificationAPI.NotificationMetadata { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - /// There was a bug at one point where the metadata would include a `null` value for the namespace because we were storing - /// messages in a namespace that the storage server didn't have an explicit `namespace_id` for, as a result we need to assume - /// that the `namespace` value may not be present in the payload - let namespace: SnodeAPI.Namespace = try container.decodeIfPresent(Int.self, forKey: .namespace) - .map { SnodeAPI.Namespace(rawValue: $0) } - .defaulting(to: .unknown) + let namespace: SnodeAPI.Namespace = SnodeAPI.Namespace( + rawValue: try container.decode(Int.self, forKey: .namespace) + ).defaulting(to: .unknown) self = PushNotificationAPI.NotificationMetadata( accountId: try container.decode(String.self, forKey: .accountId), diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift index 1da3c69284..5138d5d8f5 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift @@ -33,34 +33,36 @@ extension PushNotificationAPI { private let notificationsEncryptionKey: Data override var verificationBytes: [UInt8] { - /// The signature data collected and stored here is used by the PN server to subscribe to the swarms - /// for the given account; the specific rules are governed by the storage server, but in general: - /// - /// A signature must have been produced (via the timestamp) within the past 14 days. It is - /// recommended that clients generate a new signature whenever they re-subscribe, and that - /// re-subscriptions happen more frequently than once every 14 days. - /// - /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using - /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), - /// and signs the value: - /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` - /// - /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending - /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a - /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as - /// the `namespaces` parameter. - "MONITOR".bytes - .appending(contentsOf: authMethod.swarmPublicKey.bytes) - .appending(contentsOf: "\(timestamp)".bytes) - .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) - .appending( - contentsOf: namespaces - .map { $0.rawValue } // Intentionally not using `verificationString` here - .sorted() - .map { "\($0)" } - .joined(separator: ",") - .bytes - ) + get throws { + /// The signature data collected and stored here is used by the PN server to subscribe to the swarms + /// for the given account; the specific rules are governed by the storage server, but in general: + /// + /// A signature must have been produced (via the timestamp) within the past 14 days. It is + /// recommended that clients generate a new signature whenever they re-subscribe, and that + /// re-subscriptions happen more frequently than once every 14 days. + /// + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using + /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), + /// and signs the value: + /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending + /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a + /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as + /// the `namespaces` parameter. + "MONITOR".bytes + .appending(contentsOf: try authMethod.swarmPublicKey.bytes) + .appending(contentsOf: "\(timestamp)".bytes) + .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) + .appending( + contentsOf: namespaces + .map { $0.rawValue } // Intentionally not using `verificationString` here + .sorted() + .map { "\($0)" } + .joined(separator: ",") + .bytes + ) + } } // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift index 53c4591b7f..1d29c882d8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift @@ -19,15 +19,17 @@ extension PushNotificationAPI { private let serviceInfo: ServiceInfo override var verificationBytes: [UInt8] { - /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using - /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), - /// and signs the value: - /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` - /// - /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. - "UNSUBSCRIBE".bytes - .appending(contentsOf: authMethod.swarmPublicKey.bytes) - .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + get throws { + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subaccount, if using + /// subaccount authentication with a `subaccount_token`, for future closed group subscriptions), + /// and signs the value: + /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. + "UNSUBSCRIBE".bytes + .appending(contentsOf: try authMethod.swarmPublicKey.bytes) + .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + } } // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index e9d5f94d05..eabdd2c115 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -28,7 +28,11 @@ public protocol NotificationsManagerType { func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool - func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) + func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) func scheduleSessionNetworkPageLocalNotifcation(force: Bool) func addNotificationRequest( content: NotificationContent, @@ -100,14 +104,6 @@ public extension NotificationsManagerType { case .missed, .permissionDenied, .permissionDeniedMicrophone: break default: throw MessageReceiverError.ignorableMessage } - - /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can - /// related to the same call - try MessageDeduplication.ensureCallMessageIsNotADuplicate( - threadId: threadId, - callMessage: callMessage, - using: dependencies - ) /// Group invitations and promotions may show notifications in some cases case is GroupUpdateInviteMessage, is GroupUpdatePromoteMessage: break @@ -378,7 +374,11 @@ public struct NoopNotificationsManager: NotificationsManagerType { return false } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) {} + public func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) {} public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) {} public func addNotificationRequest( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index ccfbad27a5..6b72fff0d3 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -123,8 +123,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let fallbackPollDelay: TimeInterval = self.nextPollDelay() cancellable = dependencies[singleton: .storage] - .readPublisher { [pollerDestination, dependencies] db in - try OpenGroupAPI.preparedCapabilities( + .readPublisher { [pollerDestination, dependencies] db -> AuthenticationMethod in + try Authentication.with( db, server: pollerDestination.target, forceBlinded: true, @@ -133,7 +133,14 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } .subscribe(on: pollerQueue, using: dependencies) .receive(on: pollerQueue, using: dependencies) - .flatMap { [dependencies] request in request.send(using: dependencies) } + .tryFlatMap { [dependencies] authMethod in + try OpenGroupAPI + .preparedCapabilities( + authMethod: authMethod, + using: dependencies + ) + .send(using: dependencies) + } .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: Database, response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)) in OpenGroupManager.handleCapabilities( db, @@ -247,6 +254,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { /// **Note:** The returned messages will have already been processed by the `Poller`, they are only returned /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) public func poll(forceSynchronousProcessing: Bool = false) -> AnyPublisher { + typealias PollInfo = ( + roomInfo: [OpenGroupAPI.RoomInfo], + lastInboxMessageId: Int64, + lastOutboxMessageId: Int64, + authMethod: AuthenticationMethod + ) let lastSuccessfulPollTimestamp: TimeInterval = (self.lastPollStart > 0 ? lastPollStart : dependencies.mutate(cache: .openGroupManager) { cache in @@ -255,16 +268,49 @@ public final class CommunityPoller: CommunityPollerType & PollerType { ) return dependencies[singleton: .storage] - .readPublisher { [pollerDestination, pollCount, dependencies] db -> Network.PreparedRequest> in - try OpenGroupAPI.preparedPoll( - db, - server: pollerDestination.target, - hasPerformedInitialPoll: (pollCount > 0), - timeSinceLastPoll: (dependencies.dateNow.timeIntervalSince1970 - lastSuccessfulPollTimestamp), - using: dependencies + .readPublisher { [pollerDestination, dependencies] db -> PollInfo in + /// **Note:** The `OpenGroup` type converts to lowercase in init + let server: String = pollerDestination.target.lowercased() + let roomInfo: [OpenGroupAPI.RoomInfo] = try OpenGroup + .select(.roomToken, .infoUpdates, .sequenceNumber) + .filter(OpenGroup.Columns.server == server) + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .asRequest(of: OpenGroupAPI.RoomInfo.self) + .fetchAll(db) + + guard !roomInfo.isEmpty else { throw OpenGroupAPIError.invalidPoll } + + return ( + roomInfo, + (try? OpenGroup + .select(.inboxLatestMessageId) + .filter(OpenGroup.Columns.server == server) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0), + (try? OpenGroup + .select(.outboxLatestMessageId) + .filter(OpenGroup.Columns.server == server) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0), + try Authentication.with(db, server: server, using: dependencies) ) } - .flatMap { [dependencies] request in request.send(using: dependencies) } + .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap), Error> in + try OpenGroupAPI + .preparedPoll( + roomInfo: pollInfo.roomInfo, + lastInboxMessageId: pollInfo.lastInboxMessageId, + lastOutboxMessageId: pollInfo.lastOutboxMessageId, + hasPerformedInitialPoll: (pollCount > 0), + timeSinceLastPoll: (dependencies.dateNow.timeIntervalSince1970 - lastSuccessfulPollTimestamp), + authMethod: pollInfo.authMethod, + using: dependencies + ) + .send(using: dependencies) + } .flatMapOptional { [weak self, failureCount, dependencies] info, response in self?.handlePollResponse( info: info, @@ -652,3 +698,7 @@ public extension CommunityPollerCacheType { return getOrCreatePoller(for: CommunityPoller.Info(server: server, pollFailureCount: 0)) } } + +// MARK: - Conformance + +extension OpenGroupAPI.RoomInfo: FetchableRecord {} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index b466a8c535..4e7c660008 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -156,7 +156,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { source: .snode(snode), swarmPublicKey: pollerDestination.target, shouldStoreMessages: shouldStoreMessages, - ignoreDedupeRecords: false, + ignoreDedupeFiles: false, forceSynchronousProcessing: forceSynchronousProcessing, sortedMessages: sortedMessages, using: dependencies @@ -231,7 +231,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { source: PollSource, swarmPublicKey: String, shouldStoreMessages: Bool, - ignoreDedupeRecords: Bool, + ignoreDedupeFiles: Bool, forceSynchronousProcessing: Bool, sortedMessages: [(namespace: SnodeAPI.Namespace, messages: [SnodeReceivedMessage], lastHash: String?)], using dependencies: Dependencies @@ -296,37 +296,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { ) hadValidHashUpdate = (message.info?.storeUpdatedLastHash(db) == true) - /// Only continue if we want to check/insert dedupe records - guard !ignoreDedupeRecords else { return processedMessage } - - /// Insert the standard dedupe record + /// Insert the standard dedupe record ignoring dedupe files if needed try MessageDeduplication.insert( db, processedMessage: processedMessage, + ignoreDedupeFiles: ignoreDedupeFiles, using: dependencies ) - /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can - /// related to the same call - switch processedMessage { - case .standard(let threadId, _, _, let messageInfo, _): - guard let callMessage: CallMessage = messageInfo.message as? CallMessage else { - break - } - - try MessageDeduplication.ensureCallMessageIsNotADuplicate( - threadId: threadId, - callMessage: callMessage, - using: dependencies - ) - try dependencies[singleton: .extensionHelper].createDedupeRecord( - threadId: threadId, - uniqueIdentifier: callMessage.uuid - ) - - default: break - } - return processedMessage } catch { diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index 233760ac58..3e3cd77d09 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -372,7 +372,6 @@ public extension MessageViewModel.DeletionBehaviours { ) let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( - db, message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: threadData.currentUserSessionId @@ -384,9 +383,17 @@ public extension MessageViewModel.DeletionBehaviours { to: .contact(publicKey: model.threadId), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) + .map { _, _ in () } } /// Batch requests have a limited number of subrequests so make sure to chunk @@ -443,7 +450,6 @@ public extension MessageViewModel.DeletionBehaviours { .filter { isAdmin || (threadData.currentUserSessionIds ?? []).contains($0.authorId) } let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( - db, message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: (model.variant == .standardOutgoing ? @@ -458,9 +464,17 @@ public extension MessageViewModel.DeletionBehaviours { to: .closedGroup(groupPublicKey: model.threadId), namespace: .legacyClosedGroup, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) + .map { _, _ in () } } /// Batch requests have a limited number of subrequests so make sure to chunk @@ -508,7 +522,6 @@ public extension MessageViewModel.DeletionBehaviours { .appending(serverHashes.isEmpty ? nil : .preparedRequest(try MessageSender .preparedSend( - db, message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), @@ -519,9 +532,18 @@ public extension MessageViewModel.DeletionBehaviours { to: .closedGroup(groupPublicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - )) + ) + .map { _, _ in () } + ) ) .appending( .markAsDeleted( @@ -558,7 +580,6 @@ public extension MessageViewModel.DeletionBehaviours { .appending(serverHashes.isEmpty ? nil : .preparedRequest(try MessageSender .preparedSend( - db, message: GroupUpdateDeleteMemberContentMessage( memberSessionIds: [], messageHashes: Array(serverHashes), @@ -572,9 +593,18 @@ public extension MessageViewModel.DeletionBehaviours { to: .closedGroup(groupPublicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ), + onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - )) + ) + .map { _, _ in () } + ) ) .appending(serverHashes.isEmpty ? nil : .preparedRequest(try SnodeAPI @@ -613,14 +643,19 @@ public extension MessageViewModel.DeletionBehaviours { throw StorageError.objectNotFound } + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + using: dependencies + ) let deleteRequests: [Network.PreparedRequest] = try cellViewModels .compactMap { $0.openGroupServerMessageId } .map { messageId in try OpenGroupAPI.preparedMessageDelete( - db, id: messageId, - in: roomToken, - on: server, + roomToken: roomToken, + authMethod: authMethod, using: dependencies ) } @@ -634,9 +669,8 @@ public extension MessageViewModel.DeletionBehaviours { .map { deleteRequestsChunk in .preparedRequest( try OpenGroupAPI.preparedBatch( - db, - server: server, requests: deleteRequestsChunk, + authMethod: authMethod, using: dependencies ) .map { _, _ in () } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index fbdd059f58..e3e15702af 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -69,6 +69,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat case openGroupPublicKey case openGroupUserCount case openGroupPermissions + case openGroupCapabilities // Interaction display info @@ -171,6 +172,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let openGroupPublicKey: String? private let openGroupUserCount: Int? private let openGroupPermissions: OpenGroup.Permissions? + public let openGroupCapabilities: Set? // Interaction display info @@ -516,6 +518,7 @@ public extension SessionThreadViewModel { self.openGroupPublicKey = nil self.openGroupUserCount = nil self.openGroupPermissions = openGroupPermissions + self.openGroupCapabilities = nil // Interaction display info @@ -543,74 +546,9 @@ public extension SessionThreadViewModel { // MARK: - Mutation public extension SessionThreadViewModel { - func with( - recentReactionEmoji: [String]? = nil - ) -> SessionThreadViewModel { - return SessionThreadViewModel( - rowId: self.rowId, - threadId: self.threadId, - threadVariant: self.threadVariant, - threadCreationDateTimestamp: self.threadCreationDateTimestamp, - threadMemberNames: self.threadMemberNames, - threadIsNoteToSelf: self.threadIsNoteToSelf, - outdatedMemberId: self.outdatedMemberId, - threadIsMessageRequest: self.threadIsMessageRequest, - threadRequiresApproval: self.threadRequiresApproval, - threadShouldBeVisible: self.threadShouldBeVisible, - threadPinnedPriority: self.threadPinnedPriority, - threadIsBlocked: self.threadIsBlocked, - threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, - threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, - threadMessageDraft: self.threadMessageDraft, - threadIsDraft: self.threadIsDraft, - threadContactIsTyping: self.threadContactIsTyping, - threadWasMarkedUnread: self.threadWasMarkedUnread, - threadUnreadCount: self.threadUnreadCount, - threadUnreadMentionCount: self.threadUnreadMentionCount, - threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, - threadCanWrite: self.threadCanWrite, - disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, - contactLastKnownClientVersion: self.contactLastKnownClientVersion, - displayPictureFilename: self.displayPictureFilename, - contactProfile: self.contactProfile, - closedGroupProfileFront: self.closedGroupProfileFront, - closedGroupProfileBack: self.closedGroupProfileBack, - closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, - closedGroupAdminProfile: self.closedGroupAdminProfile, - closedGroupName: self.closedGroupName, - closedGroupDescription: self.closedGroupDescription, - closedGroupUserCount: self.closedGroupUserCount, - closedGroupExpired: self.closedGroupExpired, - currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, - openGroupName: self.openGroupName, - openGroupDescription: self.openGroupDescription, - openGroupServer: self.openGroupServer, - openGroupRoomToken: self.openGroupRoomToken, - openGroupPublicKey: self.openGroupPublicKey, - openGroupUserCount: self.openGroupUserCount, - openGroupPermissions: self.openGroupPermissions, - interactionId: self.interactionId, - interactionVariant: self.interactionVariant, - interactionTimestampMs: self.interactionTimestampMs, - interactionBody: self.interactionBody, - interactionState: self.interactionState, - interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, - interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, - interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, - interactionAttachmentCount: self.interactionAttachmentCount, - authorId: self.authorId, - threadContactNameInternal: self.threadContactNameInternal, - authorNameInternal: self.authorNameInternal, - currentUserSessionId: self.currentUserSessionId, - currentUserSessionIds: self.currentUserSessionIds, - recentReactionEmoji: (recentReactionEmoji ?? self.recentReactionEmoji), - wasKickedFromGroup: self.wasKickedFromGroup, - groupIsDestroyed: self.groupIsDestroyed - ) - } - func populatingPostQueryData( + recentReactionEmoji: [String]?, + openGroupCapabilities: Set?, currentUserSessionIds: Set, wasKickedFromGroup: Bool, groupIsDestroyed: Bool, @@ -660,6 +598,7 @@ public extension SessionThreadViewModel { openGroupPublicKey: self.openGroupPublicKey, openGroupUserCount: self.openGroupUserCount, openGroupPermissions: self.openGroupPermissions, + openGroupCapabilities: openGroupCapabilities, interactionId: self.interactionId, interactionVariant: self.interactionVariant, interactionTimestampMs: self.interactionTimestampMs, @@ -674,7 +613,7 @@ public extension SessionThreadViewModel { authorNameInternal: self.authorNameInternal, currentUserSessionId: self.currentUserSessionId, currentUserSessionIds: currentUserSessionIds, - recentReactionEmoji: self.recentReactionEmoji, + recentReactionEmoji: recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed ) diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index 4275cc9d48..0b95ba0c48 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { - /// Used for when interacting as the current user + /// Used when interacting as the current user struct standard: AuthenticationMethod { public let sessionId: SessionId public let ed25519PublicKey: [UInt8] @@ -31,7 +31,7 @@ public extension Authentication { } } - /// Used for when interacting as a group admin + /// Used when interacting as a group admin struct groupAdmin: AuthenticationMethod { public let groupSessionId: SessionId public let ed25519SecretKey: [UInt8] @@ -52,7 +52,7 @@ public extension Authentication { } } - /// Used for when interacting as a group member + /// Used when interacting as a group member struct groupMember: AuthenticationMethod { public let groupSessionId: SessionId public let authData: Data @@ -78,6 +78,38 @@ public extension Authentication { } } } + + /// Used when interacting with a community + struct community: AuthenticationMethod { + public let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo + public let forceBlinded: Bool + + public var server: String { openGroupCapabilityInfo.server } + public var publicKey: String { openGroupCapabilityInfo.publicKey } + public var hasCapabilities: Bool { !openGroupCapabilityInfo.capabilities.isEmpty } + public var supportsBlinding: Bool { openGroupCapabilityInfo.capabilities.contains(.blind) } + + public var info: Info { + .community( + server: server, + publicKey: publicKey, + hasCapabilities: hasCapabilities, + supportsBlinding: supportsBlinding, + forceBlinded: forceBlinded + ) + } + + public init(info: LibSession.OpenGroupCapabilityInfo, forceBlinded: Bool = false) { + self.openGroupCapabilityInfo = info + self.forceBlinded = forceBlinded + } + + // MARK: - SignatureGenerator + + public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { + throw CryptoError.signatureGenerationFailed + } + } } // MARK: - Convenience @@ -88,6 +120,52 @@ fileprivate struct GroupAuthData: Codable, FetchableRecord { } public extension Authentication { + static func with( + _ db: Database, + server: String, + activeOnly: Bool = true, + forceBlinded: Bool = false, + using dependencies: Dependencies + ) throws -> AuthenticationMethod { + guard + // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, server: server, activeOnly: activeOnly) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + } + + static func with( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + forceBlinded: Bool = false, + using dependencies: Dependencies + ) throws -> AuthenticationMethod { + switch (threadVariant, try? SessionId.Prefix(from: threadId)) { + case (.community, _): + guard + // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + + case (.contact, .blinded15), (.contact, .blinded25): + guard + let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: threadId), + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, server: lookup.openGroupServer) + else { throw CryptoError.invalidAuthentication } + + return Authentication.community(info: info, forceBlinded: forceBlinded) + + default: return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + } + } + static func with( _ db: Database, swarmPublicKey: String, @@ -127,10 +205,10 @@ public extension Authentication { authData: authData ) - default: throw SnodeAPIError.invalidAuthentication + default: throw CryptoError.invalidAuthentication } - default: throw SnodeAPIError.invalidAuthentication + default: throw CryptoError.invalidAuthentication } } } diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index c6f81510f8..9f2d8c8862 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -542,7 +542,7 @@ public class ExtensionHelper: ExtensionHelperType { source: .pushNotification, swarmPublicKey: swarmPublicKey, shouldStoreMessages: true, - ignoreDedupeRecords: true, + ignoreDedupeFiles: true, forceSynchronousProcessing: true, sortedMessages: sortedMessages, using: dependencies @@ -608,7 +608,7 @@ public class ExtensionHelper: ExtensionHelperType { source: .pushNotification, swarmPublicKey: message.swarmPublicKey, shouldStoreMessages: true, - ignoreDedupeRecords: true, + ignoreDedupeFiles: true, forceSynchronousProcessing: true, sortedMessages: [(message.namespace, [message], nil)], using: dependencies diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift index 727c560c1c..05bdbf9dd5 100644 --- a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -44,8 +44,8 @@ class MessageDeduplicationSpec: QuickSpec { return result }() - // MARK: - a MessageDeduplication - describe("a MessageDeduplication") { + // MARK: - MessageDeduplication - Inserting + describe("MessageDeduplication") { // MARK: -- when inserting context("when inserting") { // MARK: ---- inserts a record correctly @@ -59,18 +59,19 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId", message: nil, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) } + let expectedTimestamp: Int64 = (1234567890 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000)) let records: [MessageDeduplication]? = mockStorage .read { db in try MessageDeduplication.fetchAll(db) } expect(records?.count).to(equal(1)) expect(records?.first?.threadId).to(equal("testThreadId")) expect(records?.first?.uniqueIdentifier).to(equal("testId")) - expect(records?.first?.expirationTimestampSeconds) - .to(equal(1234567890 + (SnodeReceivedMessage.serverClockToleranceMs * 2))) + expect(records?.first?.expirationTimestampSeconds).to(equal(expectedTimestamp)) expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") @@ -88,6 +89,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId", message: nil, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -110,6 +112,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -131,6 +134,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId1", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -140,6 +144,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId2", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -149,6 +154,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId3", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -165,6 +171,7 @@ class MessageDeduplicationSpec: QuickSpec { adminSignature: .standard(signature: "TestSignature".bytes) ), serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -178,6 +185,7 @@ class MessageDeduplicationSpec: QuickSpec { sentTimestampMs: 1234567890000 ), serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -187,6 +195,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId6", message: GroupUpdateMemberLeftMessage(), serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -200,6 +209,7 @@ class MessageDeduplicationSpec: QuickSpec { sentTimestampMs: 1234567800000 ), serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -209,6 +219,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId8", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -218,6 +229,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId9", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) try MessageDeduplication.insert( @@ -227,6 +239,7 @@ class MessageDeduplicationSpec: QuickSpec { uniqueIdentifier: "testId10", message: nil, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -260,6 +273,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -289,6 +303,7 @@ class MessageDeduplicationSpec: QuickSpec { ), uniqueIdentifier: "testId" ), + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -318,6 +333,7 @@ class MessageDeduplicationSpec: QuickSpec { data: Data([1, 2, 3]), uniqueIdentifier: "testId" ), + ignoreDedupeFiles: false, using: dependencies ) }.toNot(throwError()) @@ -350,6 +366,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.to(throwError(MessageReceiverError.duplicateMessage)) @@ -385,6 +402,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.to(throwError(MessageReceiverError.duplicateMessage)) @@ -407,6 +425,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.to(throwError(TestError.mock)) @@ -434,6 +453,7 @@ class MessageDeduplicationSpec: QuickSpec { legacyIdentifier: "testLegacyId", message: mockMessage, serverExpirationTimestamp: 1234567890, + ignoreDedupeFiles: false, using: dependencies ) }.to(throwError(TestError.mock)) @@ -441,6 +461,99 @@ class MessageDeduplicationSpec: QuickSpec { } } + // MARK: -- when inserting a call message + context("when inserting a call message") { + // MARK: ---- inserts a preOffer record correctly + it("inserts a preOffer record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: 1234567890 + ), + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + expect(records?.first?.uniqueIdentifier).to(equal("12345-preOffer")) + expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891)) + expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") + }) + } + + // MARK: ---- inserts a generic record correctly + it("inserts a generic record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .endCall, + sdps: [], + sentTimestampMs: 1234567890 + ), + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + expect(records?.first?.uniqueIdentifier).to(equal("12345")) + expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891)) + expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") + }) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + mockStorage.write { db in + expect { + try MessageDeduplication.insertCallDedupeRecordsIfNeeded( + db, + threadId: "testThreadId", + callMessage: nil, + expirationTimestampSeconds: 1234567891, + shouldDeleteWhenDeletingThread: false, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(0)) + expect(mockExtensionHelper).toNot(call { + try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) + }) + } + } + } + + // MARK: - MessageDeduplication - Deleting + describe("MessageDeduplication") { // MARK: -- when deleting a dedupe record context("when deleting a dedupe record") { // MARK: ---- deletes the record successfully @@ -614,7 +727,10 @@ class MessageDeduplicationSpec: QuickSpec { }) } } - + } + + // MARK: - MessageDeduplication - Creating + describe("MessageDeduplication") { // MARK: -- when creating a dedupe file context("when creating a dedupe file") { // MARK: ---- creates the file successfully @@ -731,6 +847,137 @@ class MessageDeduplicationSpec: QuickSpec { } } + // MARK: -- when creating a call message dedupe file + context("when creating a call message dedupe file") { + // MARK: ---- creates a preOffer file correctly + it("creates a preOffer file correctly") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer") + }) + } + + // MARK: ---- creates a generic file correctly + it("creates a generic file correctly") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .endCall, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345") + }) + } + + // MARK: ---- creates a files for the correct call message kinds + it("creates a files for the correct call message kinds") { + var resultIdentifiers: [String] = [] + var resultKinds: [CallMessage.Kind] = [] + + CallMessage.Kind.allCases.forEach { kind in + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .then { args in + guard let identifier: String = args[test: 1] as? String else { return } + + resultIdentifiers.append(identifier) + resultKinds.append(kind) + } + .thenReturn(()) + + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: kind, + sdps: [], + sentTimestampMs: 1234567890 + ), + using: dependencies + ) + }.toNot(throwError()) + } + + expect(resultIdentifiers).to(equal(["12345-preOffer", "12345"])) + expect(resultKinds).to(equal([.preOffer, .endCall])) + } + + // MARK: ---- creates files for the correct call message states + it("creates files for the correct call message states") { + var resultIdentifiers: [String] = [] + var resultStates: [CallMessage.MessageInfo.State] = [] + + CallMessage.MessageInfo.State.allCases.forEach { state in + let message: CallMessage = CallMessage( + uuid: "12345", + kind: .answer, + sdps: [], + sentTimestampMs: 1234567890 + ) + message.state = state + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .then { args in + guard let identifier: String = args[test: 1] as? String else { return } + + resultIdentifiers.append(identifier) + resultStates.append(state) + } + .thenReturn(()) + + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: message, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(resultIdentifiers).to(equal(["12345", "12345", "12345"])) + expect(resultStates).to(equal([.missed, .permissionDenied, .permissionDeniedMicrophone])) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + expect { + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: "testThreadId", + callMessage: nil, + using: dependencies + ) + }.toNot(throwError()) + + expect(mockExtensionHelper).toNot(call { + try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) + }) + } + } + } + + // MARK: - MessageDeduplication - Ensuring + describe("MessageDeduplication") { // MARK: -- when ensuring a message is not a duplicate context("when ensuring a message is not a duplicate") { // MARK: ---- does not throw when not a duplicate @@ -881,3 +1128,21 @@ class MessageDeduplicationSpec: QuickSpec { } } } + +// MARK: - Convenience + +extension CallMessage.Kind: @retroactive CaseIterable { + public static var allCases: [CallMessage.Kind] = { + var result: [CallMessage.Kind] = [] + switch CallMessage.Kind.preOffer { + case .preOffer: result.append(.preOffer); fallthrough + case .offer: result.append(.offer); fallthrough + case .answer: result.append(.answer); fallthrough + case .provisionalAnswer: result.append(.provisionalAnswer); fallthrough + case .iceCandidates: result.append(.iceCandidates(sdpMLineIndexes: [], sdpMids: [])); fallthrough + case .endCall: result.append(.endCall) + } + + return result + }() +} diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 715802d79b..9bfc27954a 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -562,10 +562,17 @@ class DisplayPictureDownloadJobSpec: QuickSpec { ) let expectedRequest: Network.PreparedRequest = mockStorage.read { db in try OpenGroupAPI.preparedDownload( - db, fileId: "12", - from: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.serverPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) }! diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 24bda0cfc2..207b5b57ee 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -103,10 +103,43 @@ class MessageSendJobSpec: QuickSpec { expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when not give a thread id + it("fails when not give a thread id") { + job = Job( + variant: .messageSend, + threadId: nil, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + scheduler: DispatchQueue.main, + success: { _, _ in }, + failure: { _, runError, runPermanentFailure in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _ in }, + using: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + // MARK: -- fails when given incorrect details it("fails when given incorrect details") { job = Job( variant: .messageSend, + threadId: "Test", details: MessageReceiveJob.Details( messages: [MessageReceiveJob.Details.MessageInfo]() ) @@ -160,6 +193,7 @@ class MessageSendJobSpec: QuickSpec { ) job = Job( variant: .messageSend, + threadId: "Test1", interactionId: interaction.id!, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -179,6 +213,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no job id") { job = Job( variant: .messageSend, + threadId: "Test1", interactionId: interaction.id!, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -211,6 +246,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no interaction id") { job = Job( variant: .messageSend, + threadId: "Test1", details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), message: VisibleMessage( @@ -242,6 +278,7 @@ class MessageSendJobSpec: QuickSpec { it("fails when there is no interaction for the provided interaction id") { job = Job( variant: .messageSend, + threadId: "Test1", interactionId: 12345, details: MessageSendJob.Details( destination: .contact(publicKey: "Test"), @@ -395,6 +432,7 @@ class MessageSendJobSpec: QuickSpec { behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, + threadId: "Test1", interactionId: 100, details: AttachmentUploadJob.Details( messageSendJobId: 54321, diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 4d128d6c0f..2d98c5f00e 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -254,8 +254,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: OpenGroupAPI.defaultServer, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index d5507c7ef9..fa13eeea02 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -19,36 +19,6 @@ class OpenGroupAPISpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - using: dependencies, - initialData: { db in - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) - - try OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - ) @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork() @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in @@ -86,6 +56,12 @@ class OpenGroupAPISpec: QuickSpec { secretKey: Array(Data(hex: TestConstants.edSecretKey)) ) ) + crypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.publicKey))) + crypto + .when { $0.generate(.x25519(ed25519Seckey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.privateKey))) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -97,6 +73,9 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) + @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( + initialSetup: { $0.defaultInitialSetup() } + ) @TestState var disposables: [AnyCancellable]! = [] @TestState var error: Error? @@ -104,17 +83,35 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a poll request context("when preparing a poll request") { + @TestState var preparedRequest: Network.PreparedRequest>? + // MARK: ---- generates the correct request it("generates the correct request") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/batch")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -129,15 +126,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was no last message it("retrieves recent messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesRecent("testRoom"))) @@ -145,20 +158,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 121)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 121 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: (CommunityPoller.maxInactivityPeriod + 1), + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesRecent("testRoom"))) @@ -166,20 +190,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 122)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 122 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: false, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesSince("testRoom", seqNo: 122))) @@ -187,20 +222,31 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- retrieves recent messages if there was a last message and there has already been a poll this session it("retrieves recent messages if there was a last message and there has already been a poll this session") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 123 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints[test: 2].asType(OpenGroupAPI.Endpoint.self)) .to(equal(.roomMessagesSince("testRoom", seqNo: 123))) @@ -208,24 +254,33 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ does not call the inbox and outbox endpoints it("does not call the inbox and outbox endpoints") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.outbox)) @@ -235,26 +290,36 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when blinded and checking for message requests context("when blinded and checking for message requests") { beforeEach { - mockStorage.write { db in - db[.checkForCommunityMessageRequests] = true - - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } + mockLibSessionCache.when { $0.get(.checkForCommunityMessageRequests) }.thenReturn(true) } // MARK: ------ includes the inbox and outbox endpoints it("includes the inbox and outbox endpoints") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inbox)) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outbox)) @@ -262,70 +327,124 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ------ retrieves recent inbox messages if there was no last message it("retrieves recent inbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inbox)) } // MARK: ------ retrieves inbox messages since the last message if there was one it("retrieves inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 124, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.inboxSince(id: 124))) } // MARK: ------ retrieves recent outbox messages if there was no last message it("retrieves recent outbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outbox)) } // MARK: ------ retrieves outbox messages since the last message if there was one it("retrieves outbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 125, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).to(contain(.outboxSince(id: 125))) } @@ -334,61 +453,98 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when blinded and not checking for message requests context("when blinded and not checking for message requests") { beforeEach { - mockStorage.write { db in - db[.checkForCommunityMessageRequests] = false - - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } + mockLibSessionCache.when { $0.get(.checkForCommunityMessageRequests) }.thenReturn(false) } // MARK: ------ includes the inbox and outbox endpoints it("does not include the inbox endpoint") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, + hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) } // MARK: ------ does not retrieve recent inbox messages if there was no last message it("does not retrieve recent inbox messages if there was no last message") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 0, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inbox)) } // MARK: ------ does not retrieve inbox messages since the last message if there was one it("does not retrieve inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedPoll( + roomInfo: [ + OpenGroupAPI.RoomInfo( + roomToken: "testRoom", + infoUpdates: 0, + sequenceNumber: 0 + ) + ], + lastInboxMessageId: 124, + lastOutboxMessageId: 0, hasPerformedInitialPoll: true, timeSinceLastPoll: 0, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints as? [OpenGroupAPI.Endpoint]).toNot(contain(.inboxSince(id: 124))) } @@ -399,13 +555,21 @@ class OpenGroupAPISpec: QuickSpec { context("when preparing a capabilities request") { // MARK: ---- generates the request correctly it("generates the request and handles the response correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilities( - db, - server: "testserver", + var preparedRequest: Network.PreparedRequest? + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilities( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/capabilities")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -416,13 +580,21 @@ class OpenGroupAPISpec: QuickSpec { context("when preparing a rooms request") { // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -431,16 +603,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a capabilitiesAndRoom request context("when preparing a capabilitiesAndRoom request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints.count).to(equal(2)) expect(preparedRequest?.batchEndpoints[test: 0].asType(OpenGroupAPI.Endpoint.self)) @@ -460,16 +641,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -479,7 +668,6 @@ class OpenGroupAPISpec: QuickSpec { } // MARK: ---- and given an invalid response - context("and given an invalid response") { // MARK: ------ errors when not given a room response it("errors when not given a room response") { @@ -489,16 +677,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -515,16 +711,24 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRoom( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -539,15 +743,24 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a capabilitiesAndRooms request context("when preparing a capabilitiesAndRooms request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.batchEndpoints.count).to(equal(2)) expect(preparedRequest?.batchEndpoints[test: 0].asType(OpenGroupAPI.Endpoint.self)) @@ -567,15 +780,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -602,15 +823,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -627,15 +856,23 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedCapabilitiesAndRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -648,20 +885,29 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a send message request context("when preparing a send message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -669,27 +915,27 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) @@ -697,123 +943,113 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no user key pair - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is ed25519Seed + it("fails to sign if there is ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", + roomToken: "testRoom", whisperTo: nil, whisperMods: false, fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) @@ -821,61 +1057,57 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no ed25519SecretKey - it("fails to sign if there is no ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -885,27 +1117,26 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedSend( + plaintext: "test".data(using: .utf8)!, + roomToken: "testRoom", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } @@ -913,17 +1144,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an individual message request context("when preparing an individual message request") { + var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -932,30 +1172,28 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an update message request context("when preparing an update message request") { - beforeEach { - mockStorage.write { db in - _ = try Identity - .filter(id: .ed25519PublicKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - _ = try Identity - .filter(id: .ed25519SecretKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("PUT")) @@ -963,26 +1201,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) @@ -990,119 +1228,109 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no user key pair - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ------ fails to sign if no signature is generated it("fails to sign if no signature is generated") { - mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset mockCrypto .when { $0.generate(.signatureXed25519(data: .any, curve25519PrivateKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs the message correctly it("signs the message correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( id: 123, plaintext: "test".data(using: .utf8)!, fileIds: nil, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) @@ -1110,59 +1338,55 @@ class OpenGroupAPISpec: QuickSpec { expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) } - // MARK: ------ fails to sign if there is no open group - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is ed25519SecretKey - it("fails to sign if there is ed25519SecretKey") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) + // MARK: ------ fails to sign if there is no ed25519Seed + it("fails to sign if there is no ed25519Seed") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([]) + + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -1172,26 +1396,25 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect { + preparedRequest = try OpenGroupAPI.preparedMessageUpdate( + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) } } @@ -1199,17 +1422,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a delete message request context("when preparing a delete message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessageDelete( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessageDelete( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/message/123")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1218,17 +1450,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a delete all messages request context("when preparing a delete all messages request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedMessagesDeleteAll( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedMessagesDeleteAll( sessionId: "testUserId", - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/all/testUserId")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1237,17 +1478,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a pin message request context("when preparing a pin message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedPinMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedPinMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/pin/123")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1256,17 +1506,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unpin message request context("when preparing an unpin message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUnpinMessage( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUnpinMessage( id: 123, - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/unpin/123")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1275,16 +1534,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unpin all request context("when preparing an unpin all request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUnpinAll( - db, - in: "testRoom", - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedUnpinAll( + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/unpin/all")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1293,36 +1561,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when generaing an upload request context("when generaing an upload request") { - beforeEach { - mockStorage.write { db in - try OpenGroup( - server: "http://oxen.io", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUpload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUpload( data: Data([1, 2, 3]), - to: "testRoom", - on: "testServer", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/testRoom/file")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1331,24 +1589,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when generaing a download request context("when generaing a download request") { - beforeEach { - mockStorage.write { db in - try OpenGroup( - server: "http://oxen.io", - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - isActive: true, - name: "Test", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } + @TestState var preparedRequest: Network.PreparedRequest? // MARK: ---- generates the download url string correctly it("generates the download url string correctly") { @@ -1358,15 +1599,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- generates the download destination correctly when given an id it("generates the download destination correctly when given an id") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedDownload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedDownload( fileId: "1", - from: "roomToken", - on: "http://oxen.io", + roomToken: "roomToken", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/roomToken/file/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1380,15 +1628,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- generates the download request correctly when given a URL it("generates the download request correctly when given a URL") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedDownload( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedDownload( url: URL(string: "http://oxen.io/room/roomToken/file/1")!, - from: "roomToken", - on: "http://oxen.io", + roomToken: "roomToken", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/room/roomToken/file/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1403,15 +1658,24 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an inbox request context("when preparing an inbox request") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? = mockStorage.read { db in - try OpenGroupAPI.preparedInbox( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedInbox( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1420,16 +1684,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an inbox since request context("when preparing an inbox since request") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.DirectMessage]?>? = mockStorage.read { db in - try OpenGroupAPI.preparedInboxSince( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedInboxSince( id: 1, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox/since/1")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1438,15 +1711,24 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a clear inbox request context("when preparing an inbox since request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedClearInbox( - db, - on: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedClearInbox( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox")) expect(preparedRequest?.method.rawValue).to(equal("DELETE")) @@ -1455,17 +1737,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a send direct message request context("when preparing a send direct message request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedSend( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedSend( ciphertext: "test".data(using: .utf8)!, toInboxFor: "testUserId", - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/inbox/testUserId")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1476,18 +1767,27 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when preparing a ban user request context("when preparing a ban user request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/ban")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1495,16 +1795,23 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global ban if no room tokens are provided it("does a global ban if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) @@ -1514,16 +1821,23 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific bans if room tokens are provided it("does room specific bans if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBan( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBan( sessionId: "testUserId", for: nil, from: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) @@ -1534,17 +1848,26 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing an unban user request context("when preparing an unban user request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/unban")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1552,15 +1875,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global unban if no room tokens are provided it("does a global unban if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) @@ -1570,15 +1900,22 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific unbans if room tokens are provided it("does room specific unbans if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserUnban( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserUnban( sessionId: "testUserId", from: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) @@ -1589,20 +1926,29 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when preparing a user permissions request context("when preparing a user permissions request") { + @TestState var preparedRequest: Network.PreparedRequest? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/user/testUserId/moderator")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1610,18 +1956,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does a global update if no room tokens are provided it("does a global update if no room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: nil, - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) @@ -1631,18 +1984,25 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- does room specific updates if room tokens are provided it("does room specific updates if room tokens are provided") { - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - try OpenGroupAPI.preparedUserModeratorUpdate( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( sessionId: "testUserId", moderator: true, admin: nil, visible: true, for: ["testRoom"], - on: "testserver", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.body? .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) @@ -1652,44 +2012,52 @@ class OpenGroupAPISpec: QuickSpec { // MARK: ---- fails if neither moderator or admin are set it("fails if neither moderator or admin are set") { - var preparationError: Error? - let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: nil, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedUserModeratorUpdate( + sessionId: "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(NetworkError.invalidPreparedRequest)) - expect(preparationError).to(matchError(NetworkError.invalidPreparedRequest)) expect(preparedRequest).to(beNil()) } } // MARK: -- when preparing a ban and delete all request context("when preparing a ban and delete all request") { + @TestState var preparedRequest: Network.PreparedRequest>? + // MARK: ---- generates the request correctly it("generates the request correctly") { - let preparedRequest: Network.PreparedRequest>? = mockStorage.read { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, + expect { + preparedRequest = try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( sessionId: "testUserId", - in: "testRoom", - on: "testserver", + roomToken: "testRoom", + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/sequence")) expect(preparedRequest?.method.rawValue).to(equal("POST")) @@ -1705,97 +2073,48 @@ class OpenGroupAPISpec: QuickSpec { describe("an OpenGroupAPI") { // MARK: -- when signing context("when signing") { - // MARK: ---- fails when there is no serverPublicKey - it("fails when there is no serverPublicKey") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.noPublicKey)) - expect(preparedRequest).to(beNil()) - } + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? // MARK: ---- fails when there is no ed25519SecretKey it("fails when there is no ed25519SecretKey") { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } - - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) - expect(preparedRequest).to(beNil()) - } - - // MARK: ---- fails when the serverPublicKey is not a hex string - it("fails when the serverPublicKey is not a hex string") { - mockStorage.write { db in - _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) - } - - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } // MARK: ---- when unblinded context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - // MARK: ------ signs correctly it("signs correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1818,45 +2137,43 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenThrow(CryptoError.failedToGenerateOutput) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } // MARK: ---- when blinded context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - // MARK: ------ signs correctly it("signs correctly") { - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), using: dependencies ) - } + }.toNot(throwError()) expect(preparedRequest?.path).to(equal("/rooms")) expect(preparedRequest?.method.rawValue).to(equal("GET")) @@ -1879,22 +2196,21 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } @@ -1904,22 +2220,21 @@ class OpenGroupAPISpec: QuickSpec { .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn(nil) - var preparationError: Error? - let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in - do { - return try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - catch { - preparationError = error - throw error - } - } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [.sogs, .blind] + ), + forceBlinded: false + ), + using: dependencies + ) + }.to(throwError(OpenGroupAPIError.signingFailed)) - expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) expect(preparedRequest).to(beNil()) } } @@ -1927,6 +2242,8 @@ class OpenGroupAPISpec: QuickSpec { // MARK: -- when sending context("when sending") { + @TestState var preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? + beforeEach { mockNetwork .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } @@ -1937,15 +2254,23 @@ class OpenGroupAPISpec: QuickSpec { it("triggers sending correctly") { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + expect { + preparedRequest = try OpenGroupAPI.preparedRooms( + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: "testserver", + publicKey: TestConstants.publicKey, + capabilities: [] + ), + forceBlinded: false + ), + using: dependencies + ) + }.toNot(throwError()) + + preparedRequest? + .send(using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 0e90fe9c92..7cddfd4c4c 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -2545,7 +2545,9 @@ class OpenGroupManagerSpec: QuickSpec { context("when accessing the default rooms publisher") { // MARK: ---- starts a job to retrieve the default rooms if we have none it("starts a job to retrieve the default rooms if we have none") { - mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) + mockAppGroupDefaults + .when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) } + .thenReturn(true) mockStorage.write { db in try OpenGroup( server: OpenGroupAPI.defaultServer, @@ -2560,8 +2562,15 @@ class OpenGroupManagerSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: OpenGroupAPI.defaultServer, + authMethod: Authentication.community( + info: LibSession.OpenGroupCapabilityInfo( + roomToken: "", + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey, + capabilities: [] + ), + forceBlinded: false + ), using: dependencies ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index d63b05b4c7..3c23cea5d6 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -457,7 +457,7 @@ class MessageSenderGroupsSpec: QuickSpec { .preparedSendMessage( message: SnodeMessage( recipient: groupId.hexString, - data: Data([1, 2, 3]).base64EncodedString(), + data: Data([1, 2, 3]), ttl: ConfigDump.Variant.groupInfo.ttl, timestampMs: 1234567890 ), @@ -1046,7 +1046,7 @@ class MessageSenderGroupsSpec: QuickSpec { .appending(try SnodeAPI.preparedSendMessage( message: SnodeMessage( recipient: groupId.hexString, - data: requestDataString, + data: Data(base64Encoded: requestDataString)!, ttl: ConfigDump.Variant.groupKeys.ttl, timestampMs: UInt64(1234567890000) ), diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift index 7d0a1d3717..5e67562442 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift @@ -59,6 +59,8 @@ class MessageSenderSpec: QuickSpec { describe("a MessageSender") { // MARK: -- when preparing to send to a contact context("when preparing to send to a contact") { + @TestState var preparedRequest: Network.PreparedRequest? + beforeEach { mockCrypto .when { @@ -72,21 +74,26 @@ class MessageSenderSpec: QuickSpec { // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Network.PreparedRequest? = mockStorage.read { db in - try? MessageSender.preparedSend( - db, + expect { + preparedRequest = try MessageSender.preparedSend( message: VisibleMessage( text: "TestMessage" ), to: .contact(publicKey: "05\(TestConstants.publicKey)"), namespace: .default, interactionId: nil, - fileIds: [], + attachments: nil, + authMethod: Authentication.standard( + sessionId: SessionId(.standard, hex: TestConstants.publicKey), + ed25519PublicKey: Array(Data(hex: TestConstants.edPublicKey)), + ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)) + ), + onEvent: nil, using: dependencies ) - } + }.toNot(throwError()) - expect(result).toNot(beNil()) + expect(preparedRequest).toNot(beNil()) } } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift index 7deede79e1..516ed2bc60 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift @@ -36,8 +36,12 @@ public class MockNotificationsManager: Mock, Notificat return mock(args: [applicationState]) } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { - mockNoReturn(args: [thread, applicationState], untrackedArgs: [db]) + public func notifyForFailedSend( + threadId: String, + threadVariant: SessionThread.Variant, + applicationState: UIApplication.State + ) { + mockNoReturn(args: [threadId, threadVariant, applicationState]) } public func scheduleSessionNetworkPageLocalNotifcation(force: Bool) { diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 1838b3c427..40ac9df4cf 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -43,7 +43,7 @@ public class NSENotificationPresenter: NotificationsManagerType { // MARK: - Presentation - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { + public func notifyForFailedSend(threadId: String, threadVariant: SessionThread.Variant, applicationState: UIApplication.State) { // Not possible in the NotificationServiceExtension } diff --git a/SessionNotificationServiceExtension/NotificationResolution.swift b/SessionNotificationServiceExtension/NotificationResolution.swift index eaeef68855..08f4175c70 100644 --- a/SessionNotificationServiceExtension/NotificationResolution.swift +++ b/SessionNotificationServiceExtension/NotificationResolution.swift @@ -18,14 +18,12 @@ enum NotificationResolution: CustomStringConvertible { case ignoreDueToRequiresNoNotification case ignoreDueToMessageRequest case ignoreDueToDuplicateMessage + case ignoreDueToDuplicateCall case ignoreDueToContentSize(PushNotificationAPI.NotificationMetadata) case errorTimeout case errorNotReadyForExtensions case errorLegacyPushNotification - case errorDatabaseInvalid - case errorDatabaseMigrations(Error) - case errorTransactionFailure case errorCallFailure case errorNoContent(PushNotificationAPI.NotificationMetadata) case errorProcessing(PushNotificationAPI.ProcessResult) @@ -46,6 +44,9 @@ enum NotificationResolution: CustomStringConvertible { case .ignoreDueToDuplicateMessage: return "Ignored: Duplicate message (probably received it just before going to the background)" + + case .ignoreDueToDuplicateCall: + return "Ignored: Duplicate call (probably received after the call ended)" case .ignoreDueToContentSize(let metadata): return "Ignored: Notification content from namespace: \(metadata.namespace) was too long: \(metadata.dataLength)" @@ -53,9 +54,6 @@ enum NotificationResolution: CustomStringConvertible { case .errorTimeout: return "Failed: Execution time expired" case .errorNotReadyForExtensions: return "Failed: App not ready for extensions" case .errorLegacyPushNotification: return "Failed: Legacy push notifications are no longer supported" - case .errorDatabaseInvalid: return "Failed: Database in invalid state" - case .errorDatabaseMigrations(let error): return "Failed: Database migration error: \(error)" - case .errorTransactionFailure: return "Failed: Unexpected database transaction rollback" case .errorCallFailure: return "Failed: Failed to handle call message" case .errorNoContent(let metadata): @@ -72,14 +70,13 @@ enum NotificationResolution: CustomStringConvertible { case .success, .successCall, .ignoreDueToMainAppRunning, .ignoreDueToNoContentFromApple, .ignoreDueToNonLegacyGroupLegacyNotification, .ignoreDueToOutdatedMessage, .ignoreDueToRequiresNoNotification, .ignoreDueToMessageRequest, .ignoreDueToDuplicateMessage, - .ignoreDueToContentSize: + .ignoreDueToDuplicateCall, .ignoreDueToContentSize: return .info case .errorNotReadyForExtensions, .errorLegacyPushNotification, .errorNoContent, .errorCallFailure: return .warn - case .errorTimeout, .errorDatabaseInvalid, .errorDatabaseMigrations, .errorTransactionFailure, - .errorProcessing, .errorMessageHandling, .errorOther: + case .errorTimeout, .errorProcessing, .errorMessageHandling, .errorOther: return .error } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 8d97b354fa..7a1371efea 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -173,22 +173,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension data: info.data, origin: .swarm( publicKey: info.metadata.accountId, - namespace: { - switch (info.metadata.namespace, (try? SessionId(from: info.metadata.accountId))?.prefix) { - /// There was a bug at one point where the metadata would include a `null` value for the namespace - /// because the storage server didn't have an explicit `namespace_id` for the - /// `revokedRetrievableGroupMessages` namespace - /// - /// This code tries to work around that issue - /// - /// **Note:** This issue was present in storage server version `2.10.0` but this work-around should - /// be removed once the network has been fully updated with a fix - case (.unknown, .group): - return .revokedRetrievableGroupMessages - - default: return info.metadata.namespace - } - }(), + namespace: info.metadata.namespace, serverHash: info.metadata.hash, serverTimestampMs: info.metadata.createdTimestampMs, serverExpirationTimestamp: ( @@ -220,6 +205,14 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension openGroupUrlInfo: nil /// Community PNs not currently supported ) } + + /// There is some dedupe logic for a `CallMessage` as, depending on the state of the call, we may want to + /// consider the message a duplicate + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: messageInfo.message as? CallMessage, + using: dependencies + ) } return ( @@ -443,7 +436,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Otherwise just save the message to disk case let promoteMessage as GroupUpdatePromoteMessage: guard - let sender: String = promoteMessage.sender, let sentTimestampMs: UInt64 = promoteMessage.sentTimestampMs, let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: Array(promoteMessage.groupIdentitySeed)) @@ -579,14 +571,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case .provisionalAnswer, .iceCandidates: break } - /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the - /// same call so we need to ensure the call message itself isn't a duplicate - try MessageDeduplication.ensureCallMessageIsNotADuplicate( - threadId: threadId, - callMessage: callMessage, - using: dependencies - ) - // TODO: [Database Relocation] Need to store 'db[.areCallsEnabled]' in libSession let areCallsEnabled: Bool = true // db[.areCallsEnabled] let hasMicrophonePermission: Bool = { @@ -599,23 +583,62 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension dependencies[defaults: .appGroup, key: .isCallOngoing] && (dependencies[defaults: .appGroup, key: .lastCallPreOffer] != nil) ) + /// Handle the call as needed - switch ((areCallsEnabled && hasMicrophonePermission), isCallOngoing) { - case (false, _): + switch ((areCallsEnabled && hasMicrophonePermission), isCallOngoing, callMessage.kind) { + case (false, _, _): /// Update the `CallMessage.state` value so the correct notification logic can occur callMessage.state = (areCallsEnabled ? .permissionDeniedMicrophone : .permissionDenied) - case (true, true): + case (true, true, _): + guard let sender: String = callMessage.sender else { + throw MessageReceiverError.invalidMessage + } + guard + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + else { throw SnodeAPIError.noKeyPair } + Log.info(.calls, "Sending end call message because there is an ongoing call.") - // TODO: [Database Relocation] Need to properly implement this logic (without the database requirement) - fatalError("NEED TO IMPLEMENT") -// try MessageReceiver.handleIncomingCallOfferInBusyState( -// db, -// message: callMessage, -// using: dependencies -// ) + /// Update the `CallMessage.state` value so the correct notification logic can occur + callMessage.state = .missed + + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + try MessageReceiver + .sendIncomingCallOfferInBusyStateResponse( + threadId: threadId, + message: callMessage, + disappearingMessagesConfiguration: dependencies.mutate(cache: .libSession) { cache in + cache.disappearingMessagesConfig(threadId: threadId, threadVariant: threadVariant) + }, + authMethod: Authentication.standard( + sessionId: SessionId(.standard, hex: sender), + ed25519PublicKey: userEdKeyPair.publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ), + onEvent: { _ in }, /// Do nothing for any of the message sending events + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: semaphore.signal() + case .failure(let error): + Log.error(.cat, "Failed to send incoming call offer in busy state response: \(error)") + semaphore.signal() + } + } + ) + let result = semaphore.wait(timeout: .now() + .seconds(Int(Network.defaultTimeout))) + + switch (result, hasCompleted) { + case (.timedOut, _), (_, true): throw NotificationError.timeout + case (.success, false): break /// Show the notification and write the message to disk + } - case (true, false): + case (true, false, .preOffer): guard let sender: String = callMessage.sender, let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, @@ -628,6 +651,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension }) else { throw MessageReceiverError.invalidMessage } + /// Save the message and generate any deduplication files needed + try saveMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + messageInfo: messageInfo, + currentUserSessionIds: currentUserSessionIds + ) + + /// Handle the message as a successful call return handleSuccessForIncomingCall( notification, threadVariant: threadVariant, @@ -636,6 +669,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension sentTimestampMs: sentTimestampMs, displayNameRetriever: displayNameRetriever ) + + default: break /// Send all other cases through the standard notification handling } case is VisibleMessage: break @@ -718,10 +753,12 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) ), isUnread: ( + /// Ensure the type of message can actually be unread Interaction.Variant( message: messageInfo.message, currentUserSessionIds: currentUserSessionIds )?.canBeUnread == true && + /// Ensure the message hasn't been read on another device !dependencies.mutate(cache: .libSession, { cache in cache.timestampAlreadyRead( threadId: threadId, @@ -729,7 +766,33 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension timestampMs: (messageInfo.message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread openGroupUrlInfo: nil /// Communities currently don't support PNs ) - }) + }) && + { + /// If it's not a `CallMessage` or is a `preOffer` than it can be unread + guard + let callMessage: CallMessage = messageInfo.message as? CallMessage, + callMessage.kind != .preOffer + else { return true } + + /// If there is a dedupe record for the `preOffer` of this call, or a dedupe record for the call in general + /// then it would have already incremented the unread count so this message shouldn't count + do { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.preOfferDedupeIdentifier, + using: dependencies + ) + } + catch { return false } + + /// Otherwise the call should increment the count + return true + }() ) ) } @@ -738,14 +801,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Since we successfully handled the message we should now create the dedupe file for the message so we don't /// show duplicate PNs try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) - - /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the same call - if let callMessage: CallMessage = messageInfo.message as? CallMessage { - try dependencies[singleton: .extensionHelper].createDedupeRecord( - threadId: threadId, - uniqueIdentifier: callMessage.uuid - ) - } + try MessageDeduplication.createCallDedupeFilesIfNeeded( + threadId: threadId, + callMessage: messageInfo.message as? CallMessage, + using: dependencies + ) } private func handleError( @@ -755,12 +815,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension contentHandler: ((UNNotificationContent) -> Void) ) { switch (error, (try? SessionId(from: info.metadata.accountId))?.prefix, info.metadata.namespace.isConfigNamespace) { - case (NotificationError.migration(let error), _, _): - self.completeSilenty(info, .errorDatabaseMigrations(error)) - - case (NotificationError.databaseInvalid, _, _): - self.completeSilenty(info, .errorDatabaseInvalid) - + case (NotificationError.timeout, _, _): + self.completeSilenty(info, .errorTimeout) + case (NotificationError.notReadyForExtension, _, _): self.completeSilenty(info, .errorNotReadyForExtensions) @@ -801,6 +858,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (MessageReceiverError.duplicateMessage, _, _): self.completeSilenty(info, .ignoreDueToDuplicateMessage) + case (MessageReceiverError.duplicatedCall, _, _): + self.completeSilenty(info, .ignoreDueToDuplicateCall) + /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't /// want to show the fallback notification in this case) case (MessageReceiverError.decryptionFailed, _, true): @@ -1063,11 +1123,6 @@ private extension NotificationServiceExtension { case notReadyForExtension case processingErrorWithFallback(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) case processingError(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) - - @available(*, deprecated, message: "Should be removed as part of the database relocation work once the notification extension no longer needs the database") - case migration(Error) - - @available(*, deprecated, message: "Should be removed as part of the database relocation work once the notification extension no longer needs the database") - case databaseInvalid + case timeout } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 67b44dd51d..999a465ccf 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -293,7 +293,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Interaction, [Network.PreparedRequest]) in + .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { throw MessageSenderError.noThread } @@ -360,32 +360,47 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } // Process any attachments - try Attachment.process( + try AttachmentUploader.process( db, - attachments: Attachment.prepare(attachments: finalAttachments, using: dependencies), + attachments: AttachmentUploader.prepare( + attachments: finalAttachments, + using: dependencies + ), for: interactionId ) // Using the same logic as the `MessageSendJob` retrieve + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob .fetchAttachmentState(db, interactionId: interactionId) - let preparedUploads: [Network.PreparedRequest] = try Attachment + let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) .map { attachment in - try attachment.preparedUpload( - db, - threadId: threadId, + try AttachmentUploader.preparedUpload( + attachment: attachment, logCategory: nil, + authMethod: authMethod, using: dependencies ) } + let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) + let destination: Message.Destination = try Message.Destination.from( + db, + threadId: threadId, + threadVariant: threadVariant + ) - return (interaction, preparedUploads) + return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) } - .flatMap { (interaction: Interaction, preparedUploads: [Network.PreparedRequest]) -> AnyPublisher<(interaction: Interaction, fileIds: [String]), Error> in + .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, String)]), Error> in guard !preparedUploads.isEmpty else { - return Just((interaction, [])) + return Just((message, destination, interactionId, authMethod, [])) .setFailureType(to: Error.self) .eraseToAnyPublisher() } @@ -393,30 +408,21 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return Publishers .MergeMany(preparedUploads.map { $0.send(using: dependencies) }) .collect() - .map { results in (interaction, results.map { _, id in id }) } + .map { results in (message, destination, interactionId, authMethod, results.map { _, value in value }) } .eraseToAnyPublisher() } - .flatMapStorageWritePublisher(using: dependencies) { db, info -> Network.PreparedRequest in - // Prepare the message send data - guard - let threadVariant: SessionThread.Variant = try SessionThread - .filter(id: info.interaction.threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - else { throw MessageSenderError.noThread } - - return try MessageSender - .preparedSend( - db, - interaction: info.interaction, - fileIds: info.fileIds, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) + .tryFlatMap { message, destination, interactionId, authMethod, attachments -> AnyPublisher<(ResponseInfoType, Message), Error> in + try MessageSender.preparedSend( + message: message, + to: destination, + namespace: destination.defaultNamespace, + interactionId: interactionId, + attachments: attachments, + authMethod: authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies + ).send(using: dependencies) } - .flatMap { $0.send(using: dependencies) } .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 30692d6757..dcce8dc7b0 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -53,6 +53,8 @@ public class ThreadPickerViewModel { ) return threadViewModel.populatingPostQueryData( + recentReactionEmoji: nil, + openGroupCapabilities: nil, currentUserSessionIds: [userSessionId.hexString], wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed, diff --git a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift index 58ad80dd3d..4bcaa17c42 100644 --- a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift +++ b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift @@ -50,6 +50,8 @@ public class SnodeAuthenticatedRequestBody: Encodable { case .groupMember(let sessionId, _): try container.encode(sessionId.hexString, forKey: .pubkey) + + case .community: throw CryptoError.signatureGenerationFailed } switch signature { diff --git a/SessionSnodeKit/Models/SnodeMessage.swift b/SessionSnodeKit/Models/SnodeMessage.swift index 07cf76f093..a9fbcdd1e3 100644 --- a/SessionSnodeKit/Models/SnodeMessage.swift +++ b/SessionSnodeKit/Models/SnodeMessage.swift @@ -27,9 +27,9 @@ public final class SnodeMessage: Codable { // MARK: - Initialization - public init(recipient: String, data: String, ttl: UInt64, timestampMs: UInt64) { + public init(recipient: String, data: Data, ttl: UInt64, timestampMs: UInt64) { self.recipient = recipient - self.data = data + self.data = data.base64EncodedString() self.ttl = ttl self.timestampMs = timestampMs } @@ -43,7 +43,9 @@ extension SnodeMessage { self.init( recipient: try container.decode(String.self, forKey: .recipient), - data: try container.decode(String.self, forKey: .data), + data: try Data(base64Encoded: try container.decode(String.self, forKey: .data)) ?? { + throw NetworkError.parsingFailed + }(), ttl: try container.decode(UInt64.self, forKey: .ttl), timestampMs: try container.decode(UInt64.self, forKey: .timestampMs) ) diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift index 947e55d15d..fb26688ac1 100644 --- a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI+Network.swift @@ -41,15 +41,12 @@ extension SessionNetworkAPI { .eraseToAnyPublisher() } - return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - try SessionNetworkAPI - .prepareInfo( - db, - using: dependencies - ) + return Result { + try SessionNetworkAPI + .prepareInfo(using: dependencies) } - .flatMap { $0.send(using: dependencies) } + .publisher + .flatMap { [dependencies] in $0.send(using: dependencies) } .map { _, info in info } .flatMapStorageWritePublisher(using: dependencies) { [dependencies] db, info -> Bool in // Token info diff --git a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift index 1d1891a51d..b2be2d86ba 100644 --- a/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift +++ b/SessionSnodeKit/SessionNetworkAPI/SessionNetworkAPI.swift @@ -4,7 +4,6 @@ import Foundation import Combine -import GRDB import SessionUtilitiesKit public enum SessionNetworkAPI { @@ -18,7 +17,6 @@ public enum SessionNetworkAPI { /// `GET/info` public static func prepareInfo( - _ db: Database, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( @@ -35,13 +33,12 @@ public enum SessionNetworkAPI { requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies ) - .signed(db, with: SessionNetworkAPI.signRequest, using: dependencies) + .signed(with: SessionNetworkAPI.signRequest, using: dependencies) } // MARK: - Authentication fileprivate static func signatureHeaders( - _ db: Database, url: URL, method: HTTPMethod, body: Data?, @@ -52,7 +49,6 @@ public enum SessionNetworkAPI { .appending(url.query.map { value in "?\(value)" }) let signResult: (publicKey: String, signature: [UInt8]) = try sign( - db, timestamp: timestamp, method: method.rawValue, path: path, @@ -68,7 +64,6 @@ public enum SessionNetworkAPI { } private static func sign( - _ db: Database, timestamp: UInt64, method: String, path: String, @@ -81,9 +76,11 @@ public enum SessionNetworkAPI { }() guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + !dependencies[cache: .general].ed25519SecretKey.isEmpty, let blinded07KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .versionBlinded07KeyPair(ed25519SecretKey: userEdKeyPair.secretKey) + .versionBlinded07KeyPair( + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( .signatureVersionBlind07( @@ -91,10 +88,10 @@ public enum SessionNetworkAPI { method: method, path: path, body: bodyString, - ed25519SecretKey: userEdKeyPair.secretKey + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) - else { throw NetworkError.signingFailed } + else { throw CryptoError.signatureGenerationFailed } return ( publicKey: SessionId(.versionBlinded07, publicKey: blinded07KeyPair.publicKey).hexString, @@ -103,22 +100,17 @@ public enum SessionNetworkAPI { } private static func signRequest( - _ db: Database, preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { - guard let url: URL = preparedRequest.destination.url else { - throw NetworkError.signingFailed - } - - guard case let .server(info) = preparedRequest.destination else { - throw NetworkError.signingFailed - } + guard + let url: URL = preparedRequest.destination.url, + case let .server(info) = preparedRequest.destination + else { throw NetworkError.invalidPreparedRequest } return .server( info: info.updated( with: try signatureHeaders( - db, url: url, method: preparedRequest.method, body: preparedRequest.body, diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift index e818cee76d..5ae2681825 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift @@ -68,7 +68,7 @@ public final class SnodeAPI { requests: requests, requireAllBatchResponses: true, snode: snode, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies ) .map { (_: ResponseInfoType, batchResponse: Network.BatchResponse) -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: PreparedGetMessagesResponse?)] in @@ -163,7 +163,7 @@ public final class SnodeAPI { db, for: snode, namespace: namespace, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies )? .hash @@ -173,9 +173,9 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .getMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: LegacyGetMessagesRequest( - pubkey: authMethod.swarmPublicKey, + pubkey: try authMethod.swarmPublicKey, lastHash: (maybeLastHash ?? ""), namespace: namespace, maxCount: nil, @@ -190,7 +190,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .getMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: GetMessagesRequest( lastHash: (maybeLastHash ?? ""), namespace: namespace, @@ -205,12 +205,12 @@ public final class SnodeAPI { }() return preparedRequest - .map { _, response -> (messages: [SnodeReceivedMessage], lastHash: String?) in + .tryMap { _, response -> (messages: [SnodeReceivedMessage], lastHash: String?) in return ( - response.messages.compactMap { rawMessage -> SnodeReceivedMessage? in + try response.messages.compactMap { rawMessage -> SnodeReceivedMessage? in SnodeReceivedMessage( snode: snode, - publicKey: authMethod.swarmPublicKey, + publicKey: try authMethod.swarmPublicKey, namespace: namespace, rawMessage: rawMessage ) @@ -293,7 +293,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .getExpiries, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: GetExpiriesRequest( messageHashes: serverHashes, authMethod: authMethod, @@ -319,7 +319,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .sendMessage, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: LegacySendMessagesRequest( message: message, namespace: namespace @@ -335,7 +335,7 @@ public final class SnodeAPI { return try SnodeAPI.prepareRequest( request: Request( endpoint: .sendMessage, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: SendMessageRequest( message: message, namespace: namespace, @@ -353,7 +353,7 @@ public final class SnodeAPI { return request .tryMap { _, response -> SendMessagesResponse in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, using: dependencies ) @@ -380,7 +380,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .expire, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: UpdateExpiryRequest( messageHashes: serverHashes, expiryMs: UInt64(updatedExpiryMs), @@ -395,7 +395,7 @@ public final class SnodeAPI { .tryMap { _, response -> [String: UpdateExpiryResponseResult] in do { return try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: serverHashes, using: dependencies ) @@ -453,7 +453,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .revokeSubaccount, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: RevokeSubaccountRequest( subaccountsToRevoke: subaccountsToRevoke, authMethod: authMethod, @@ -465,7 +465,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> Void in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: (subaccountsToRevoke, timestampMs), using: dependencies ) @@ -485,7 +485,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .unrevokeSubaccount, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: UnrevokeSubaccountRequest( subaccountsToUnrevoke: subaccountsToUnrevoke, authMethod: authMethod, @@ -497,7 +497,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> Void in try response.validateResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: (subaccountsToUnrevoke, timestampMs), using: dependencies ) @@ -518,7 +518,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteMessages, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, body: DeleteMessagesRequest( messageHashes: serverHashes, requireSuccessfulDeletion: requireSuccessfulDeletion, @@ -530,7 +530,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> [String: Bool] in let validResultMap: [String: Bool] = try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: serverHashes, using: dependencies ) @@ -563,7 +563,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteAll, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, requiresLatestNetworkTime: true, body: DeleteAllMessagesRequest( namespace: namespace, @@ -583,7 +583,7 @@ public final class SnodeAPI { } return try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: targetInfo.timestampMs, using: dependencies ) @@ -601,7 +601,7 @@ public final class SnodeAPI { .prepareRequest( request: Request( endpoint: .deleteAllBefore, - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, requiresLatestNetworkTime: true, body: DeleteAllBeforeRequest( beforeMs: beforeMs, @@ -616,7 +616,7 @@ public final class SnodeAPI { ) .tryMap { _, response -> [String: Bool] in try response.validResultMap( - swarmPublicKey: authMethod.swarmPublicKey, + swarmPublicKey: try authMethod.swarmPublicKey, validationData: beforeMs, using: dependencies ) diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift b/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift index 9e54ab832a..0de7eb2ba8 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPIError.swift @@ -12,7 +12,6 @@ public enum SnodeAPIError: Error, CustomStringConvertible { case noKeyPair case signingFailed case signatureVerificationFailed - case invalidAuthentication case invalidIP case responseFailedValidation case unauthorised @@ -46,7 +45,6 @@ public enum SnodeAPIError: Error, CustomStringConvertible { 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 .invalidAuthentication: return "Invalid Authentication (SnodeAPIError.invalidAuthentication)." case .invalidIP: return "Invalid IP (SnodeAPIError.invalidIP)." case .responseFailedValidation: return "Response failed validation (SnodeAPIError.responseFailedValidation)." case .unauthorised: return "Unauthorized (SnodeAPIError.unauthorised)." diff --git a/SessionSnodeKit/Types/BencodeResponse.swift b/SessionSnodeKit/Types/BencodeResponse.swift index 221129a8fd..d33325d6a4 100644 --- a/SessionSnodeKit/Types/BencodeResponse.swift +++ b/SessionSnodeKit/Types/BencodeResponse.swift @@ -12,32 +12,28 @@ extension BencodeResponse: Decodable { public init(from decoder: Decoder) throws { var container: UnkeyedDecodingContainer = try decoder.unkeyedContainer() - /// The first element will be the request info - info = try { - /// First try to decode it directly - if let info: T = try? container.decode(T.self) { - return info - } - - /// If that failed then we need to reset the container and try decode it as a JSON string - container = try decoder.unkeyedContainer() - let infoString: String = try container.decode(String.self) + do { + /// Try to decode the first element as `T` directly (this will increment the decoder past the first element whether it + /// succeeds or fails) + self.info = try container.decode(T.self) + } + catch { + /// If that failed then we need a new container in order to try to decode the first element again, so create a new one and + /// try decode the first element as a JSON string + var retryContainer: UnkeyedDecodingContainer = try decoder.unkeyedContainer() + let infoString: String = try retryContainer.decode(String.self) let infoData: Data = try infoString.data(using: .ascii) ?? { throw NetworkError.parsingFailed }() /// Pass the `dependencies` through to the `JSONDecoder` if we have them, if /// we don't then it's the responsibility of the decoding type to throw when `dependencies` /// isn't present but is required let jsonDecoder: JSONDecoder = (decoder.dependencies.map { JSONDecoder(using: $0) } ?? JSONDecoder()) - return try jsonDecoder.decode(T.self, from: infoData) - }() - - /// The second element (if present) will be the response data and should just - guard container.count == 2 else { - data = nil - return + self.info = try jsonDecoder.decode(T.self, from: infoData) } - data = try container.decode(Data.self) + /// The second element (if present) will be the response data and should just decode directly (we can use the initial + /// `container` since it should be sitting at the second element) + self.data = (container.isAtEnd ? nil : try container.decode(Data.self)) } } diff --git a/SessionSnodeKit/Types/NetworkError.swift b/SessionSnodeKit/Types/NetworkError.swift index 841e02d363..8938775521 100644 --- a/SessionSnodeKit/Types/NetworkError.swift +++ b/SessionSnodeKit/Types/NetworkError.swift @@ -8,7 +8,6 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case invalidState case invalidURL case invalidPreparedRequest - case signingFailed case forbidden case notFound case parsingFailed @@ -30,7 +29,6 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .invalidState: return "The network is in an invalid state (NetworkError.invalidState)." case .invalidURL: return "Invalid URL (NetworkError.invalidURL)." case .invalidPreparedRequest: return "Invalid PreparedRequest provided (NetworkError.invalidPreparedRequest)." - case .signingFailed: return "Couldn't sign request (NetworkError.signingFailed)." case .forbidden: return "Forbidden (NetworkError.forbidden)." case .notFound: return "Not Found (NetworkError.notFound)." case .parsingFailed: return "Invalid response (NetworkError.parsingFailed)." diff --git a/SessionSnodeKit/Types/PreparedRequest.swift b/SessionSnodeKit/Types/PreparedRequest.swift index 78db337506..67c1697324 100644 --- a/SessionSnodeKit/Types/PreparedRequest.swift +++ b/SessionSnodeKit/Types/PreparedRequest.swift @@ -447,11 +447,10 @@ extension Network.PreparedRequest: ErasedPreparedRequest { public extension Network.PreparedRequest { func signed( - _ db: Database, - with requestSigner: (Database, Network.PreparedRequest, Dependencies) throws -> Network.Destination, + with requestSigner: (Network.PreparedRequest, Dependencies) throws -> Network.Destination, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - let signedDestination: Network.Destination = try requestSigner(db, self, dependencies) + let signedDestination: Network.Destination = try requestSigner(self, dependencies) return Network.PreparedRequest( body: body, diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 1a45060f04..feac9db289 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -4,7 +4,7 @@ import UIKit public typealias ThemeSettings = (theme: Theme?, primaryColor: Theme.PrimaryColor?, matchSystemNightModeSetting: Bool?) -public enum SNUIKit { +public actor SNUIKit { public protocol ConfigType { var maxFileSize: UInt { get } var isStorageValid: Bool { get } @@ -19,52 +19,21 @@ public enum SNUIKit { func shouldShowStringKeys() -> Bool } - private static var _mainWindow: UIWindow? = nil - private static var _unsafeConfig: ConfigType? = nil + @MainActor public static var mainWindow: UIWindow? = nil + internal static var config: ConfigType? = nil - /// The `mainWindow` of the application set during application startup - /// - /// **Note:** This should only be accessed on the main thread - internal static var mainWindow: UIWindow? { - assert(Thread.isMainThread) - - return _mainWindow - } - - internal static var config: ConfigType? { - switch Thread.isMainThread { - case false: - // Don't allow config access off the main thread - print("SNUIKit Error: Attempted to access the 'SNUIKit.config' on the wrong thread") - return nil - - case true: return _unsafeConfig - } - } - - public static func setMainWindow(_ mainWindow: UIWindow) { - switch Thread.isMainThread { - case true: _mainWindow = mainWindow - case false: DispatchQueue.main.async { _mainWindow = mainWindow } - } + @MainActor public static func setMainWindow(_ mainWindow: UIWindow) { + self.mainWindow = mainWindow } - public static func configure(with config: ConfigType, themeSettings: ThemeSettings?) { - guard Thread.isMainThread else { - return DispatchQueue.main.async { - configure(with: config, themeSettings: themeSettings) - } - } - - // Apply the theme settings before storing the config so we don't needlessly update - // the settings in the database + @MainActor public static func configure(with config: ConfigType, themeSettings: ThemeSettings?) { + /// Apply the theme settings before storing the config so we don't needlessly update the settings in the database ThemeManager.updateThemeState( theme: themeSettings?.theme, primaryColor: themeSettings?.primaryColor, matchSystemNightModeSetting: themeSettings?.matchSystemNightModeSetting ) - - _unsafeConfig = config + self.config = config } internal static func themeSettingsChanged( @@ -72,16 +41,10 @@ public enum SNUIKit { _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool ) { - guard Thread.isMainThread else { - return DispatchQueue.main.async { - themeSettingsChanged(theme, primaryColor, matchSystemNightModeSetting) - } - } - config?.themeChanged(theme, primaryColor, matchSystemNightModeSetting) } - internal static func navBarSessionIcon() -> NavBarSessionIcon { + @MainActor internal static func navBarSessionIcon() -> NavBarSessionIcon { guard let config: ConfigType = self.config else { return NavBarSessionIcon() } return config.navBarSessionIcon() diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index 463893c40a..38d8333552 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -27,7 +27,7 @@ public enum ThemeManager { // MARK: - Functions - public static func updateThemeState( + @MainActor public static func updateThemeState( theme: Theme? = nil, primaryColor: Theme.PrimaryColor? = nil, matchSystemNightModeSetting: Bool? = nil @@ -61,9 +61,7 @@ public enum ThemeManager { // Note: We need to set this to 'unspecified' to force the UI to properly update as the // 'TraitObservingWindow' won't actually trigger the trait change otherwise - DispatchQueue.main.async { - SNUIKit.mainWindow?.overrideUserInterfaceStyle = .unspecified - } + SNUIKit.mainWindow?.overrideUserInterfaceStyle = .unspecified } // If the theme was changed then trigger the callback for the theme settings change (so it gets persisted) @@ -72,7 +70,7 @@ public enum ThemeManager { SNUIKit.themeSettingsChanged(targetTheme, targetPrimaryColor, targetMatchSystemNightModeSetting) } - public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + @MainActor public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { let currentUserInterfaceStyle: UIUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle // Only trigger updates if the style changed and the device is set to match the system style @@ -91,7 +89,7 @@ public enum ThemeManager { } } - public static func applyNavigationStyling() { + @MainActor public static func applyNavigationStyling() { guard Thread.isMainThread else { return DispatchQueue.main.async { applyNavigationStyling() } } @@ -178,7 +176,7 @@ public enum ThemeManager { updateIfNeeded(viewController: SNUIKit.mainWindow?.rootViewController) } - public static func applyNavigationStylingIfNeeded(to viewController: UIViewController) { + @MainActor public static func applyNavigationStylingIfNeeded(to viewController: UIViewController) { // Will use the 'primary' style for all other cases guard let navController: UINavigationController = ((viewController as? UINavigationController) ?? viewController.navigationController), @@ -214,7 +212,7 @@ public enum ThemeManager { } } - public static func applyWindowStyling() { + @MainActor public static func applyWindowStyling() { guard Thread.isMainThread else { return DispatchQueue.main.async { applyWindowStyling() } } @@ -241,7 +239,7 @@ public enum ThemeManager { ) } - private static func updateAllUI() { + @MainActor private static func updateAllUI() { guard Thread.isMainThread else { return DispatchQueue.main.async { updateAllUI() } } diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index dc07e97cb1..44b0a4494d 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -8,7 +8,7 @@ struct ViewControllerHolder { } struct ViewControllerKey: EnvironmentKey { - static var defaultValue: ViewControllerHolder { + @MainActor static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: SNUIKit.mainWindow?.rootViewController) } } diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 818a1b01b7..b5b821d651 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -12,4 +12,5 @@ public enum CryptoError: Error { case decryptionFailed case failedToGenerateOutput case missingUserSecretKey + case invalidAuthentication } diff --git a/SessionUtilitiesKit/General/AppContext.swift b/SessionUtilitiesKit/General/AppContext.swift index 0a12dac908..6982ddf7a3 100644 --- a/SessionUtilitiesKit/General/AppContext.swift +++ b/SessionUtilitiesKit/General/AppContext.swift @@ -24,7 +24,7 @@ public protocol AppContext: AnyObject { var frontMostViewController: UIViewController? { get } var backgroundTimeRemaining: TimeInterval { get } - func setMainWindow(_ mainWindow: UIWindow) + @MainActor func setMainWindow(_ mainWindow: UIWindow) func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) func beginBackgroundTask(expirationHandler: @escaping () -> ()) -> UIBackgroundTaskIdentifier func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) diff --git a/SessionUtilitiesKit/General/Authentication.swift b/SessionUtilitiesKit/General/Authentication.swift index 83e01ba803..62b3662bde 100644 --- a/SessionUtilitiesKit/General/Authentication.swift +++ b/SessionUtilitiesKit/General/Authentication.swift @@ -10,9 +10,13 @@ public protocol AuthenticationMethod: SignatureGenerator { public extension AuthenticationMethod { var swarmPublicKey: String { - switch info { - case .standard(let sessionId, _), .groupAdmin(let sessionId, _), .groupMember(let sessionId, _): - return sessionId.hexString + get throws { + switch info { + case .standard(let sessionId, _), .groupAdmin(let sessionId, _), .groupMember(let sessionId, _): + return sessionId.hexString + + case .community: throw CryptoError.invalidAuthentication + } } } } @@ -51,14 +55,23 @@ public extension Authentication { public extension Authentication { enum Info: Equatable { - /// Used for when interacting as the current user + /// Used when interacting as the current user case standard(sessionId: SessionId, ed25519PublicKey: [UInt8]) - /// Used for when interacting as a group admin + /// Used when interacting as a group admin case groupAdmin(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) - /// Used for when interacting as a group member + /// Used when interacting as a group member case groupMember(groupSessionId: SessionId, authData: Data) + + /// Used when interacting with a community + case community( + server: String, + publicKey: String, + hasCapabilities: Bool, + supportsBlinding: Bool, + forceBlinded: Bool + ) } } diff --git a/SessionUtilitiesKit/Types/BencodeDecoder.swift b/SessionUtilitiesKit/Types/BencodeDecoder.swift index 9aa56138b5..6db7fe98bd 100644 --- a/SessionUtilitiesKit/Types/BencodeDecoder.swift +++ b/SessionUtilitiesKit/Types/BencodeDecoder.swift @@ -309,13 +309,11 @@ extension _BencodeDecoder.KeyedContainer: KeyedDecodingContainerProtocol { func contains(_ key: Key) -> Bool { nestedContainers.keys.contains(key.stringValue) } func decodeNil(forKey key: Key) throws -> Bool { - throw DecodingError.typeMismatch( - Any?.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "cannot decode nil for key: \(key) (Null values are not supported)" - ) - ) + /// In Bencode, if a key is present, its value is never `nil` + /// + /// If `decodeIfPresent` calls this, it means `contains(key)` was true (the key is present and has a non-nil value) so + /// we should just return `false` + return false } func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { @@ -443,13 +441,11 @@ extension _BencodeDecoder.UnkeyedContainer: _BencodeDecodingContainer {} extension _BencodeDecoder.UnkeyedContainer: UnkeyedDecodingContainer { func decodeNil() throws -> Bool { - throw DecodingError.typeMismatch( - Any?.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "cannot decode nil for index: \(currentIndex) (Null values are not supported)" - ) - ) + /// In Bencode, if a key is present, its value is never `nil` + /// + /// If `decodeIfPresent` calls this, it means `contains(key)` was true (the key is present and has a non-nil value) so + /// we should just return `false` + return false } func decode(_ type: T.Type) throws -> T where T: Decodable { @@ -538,7 +534,7 @@ extension _BencodeDecoder { extension _BencodeDecoder.SingleValueContainer: _BencodeDecodingContainer {} extension _BencodeDecoder.SingleValueContainer: SingleValueDecodingContainer { - func decodeNil() -> Bool { return true } // Nil values are omitted in Bencoded data + func decodeNil() -> Bool { return false } // Nil values are omitted in Bencoded data func decode(_ type: Bool.Type) throws -> Bool { throw DecodingError.typeMismatch(