diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f42e21826..ce6b2ef85 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -631,6 +631,8 @@ FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; + FD4324302999F0BC008A0213 /* ValidatableResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43242F2999F0BC008A0213 /* ValidatableResponse.swift */; }; + FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432436299DEA38008A0213 /* TypeConversion+Utilities.swift */; }; FD43EE9D297A5190009C87C5 /* SessionUtil+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */; }; FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; @@ -806,6 +808,7 @@ FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDDC08F229A300E800BF9681 /* TypeConversionUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDC08F129A300E800BF9681 /* TypeConversionUtilitiesSpec.swift */; }; FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */; }; FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A229418E2F00A33BC1 /* KeyPair.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; @@ -1756,6 +1759,8 @@ FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; + FD43242F2999F0BC008A0213 /* ValidatableResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatableResponse.swift; sourceTree = ""; }; + FD432436299DEA38008A0213 /* TypeConversion+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TypeConversion+Utilities.swift"; sourceTree = ""; }; FD43EE9C297A5190009C87C5 /* SessionUtil+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Groups.swift"; sourceTree = ""; }; FD43EE9E297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+ConvoInfoVolatile.swift"; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; @@ -1922,6 +1927,7 @@ FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; + FDDC08F129A300E800BF9681 /* TypeConversionUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeConversionUtilitiesSpec.swift; sourceTree = ""; }; FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CryptoKit+Utilities.swift"; sourceTree = ""; }; FDE658A229418E2F00A33BC1 /* KeyPair.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyPair.swift; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; @@ -3853,6 +3859,14 @@ path = "Shared Models"; sourceTree = ""; }; + FD432435299DEA1C008A0213 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD432436299DEA38008A0213 /* TypeConversion+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD7115F528C8150600B47552 /* Combine */ = { isa = PBXGroup; children = ( @@ -4030,6 +4044,7 @@ children = ( FD2B4B022949886900AB4848 /* Database */, FD8ECF8E29381FB200C0D1BB /* Config Handling */, + FD432435299DEA1C008A0213 /* Utilities */, FD8ECF7829340F7100C0D1BB /* libsession-util.xcframework */, FD8ECF882935AB7200C0D1BB /* SessionUtilError.swift */, FD8ECF7A29340FFD00C0D1BB /* SessionUtil.swift */, @@ -4040,6 +4055,7 @@ FD8ECF802934385900C0D1BB /* LibSessionUtil */ = { isa = PBXGroup; children = ( + FDDC08F029A300D500BF9681 /* Utilities */, FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */, FD8ECF812934387A00C0D1BB /* ConfigUserProfileSpec.swift */, FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */, @@ -4203,6 +4219,14 @@ path = _TestUtilities; sourceTree = ""; }; + FDDC08F029A300D500BF9681 /* Utilities */ = { + isa = PBXGroup; + children = ( + FDDC08F129A300E800BF9681 /* TypeConversionUtilitiesSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDE7214E287E50D50093DF33 /* Scripts */ = { isa = PBXGroup; children = ( @@ -4259,6 +4283,7 @@ FDF848DE29405D6E007DCAE5 /* OnionRequestAPIVersion.swift */, FDF848E229405D6E007DCAE5 /* OnionRequestAPIError.swift */, FDF848E129405D6E007DCAE5 /* OnionRequestAPIDestination.swift */, + FD43242F2999F0BC008A0213 /* ValidatableResponse.swift */, ); path = Types; sourceTree = ""; @@ -5430,6 +5455,7 @@ FDF848CE29405C5B007DCAE5 /* UpdateExpiryAllRequest.swift in Sources */, FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, FDF848DC29405C5B007DCAE5 /* RevokeSubkeyRequest.swift in Sources */, + FD4324302999F0BC008A0213 /* ValidatableResponse.swift in Sources */, FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */, FDF848EC29405E4F007DCAE5 /* OnionRequestAPI+Encryption.swift in Sources */, FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, @@ -5762,6 +5788,7 @@ C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, @@ -6023,6 +6050,7 @@ buildActionMask = 2147483647; files = ( FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, + FDDC08F229A300E800BF9681 /* TypeConversionUtilitiesSpec.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 3c4901fc7..d708302f9 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -326,7 +326,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { guard shouldMarkAsRead, - let threadVariant: SessionThread.Variant = try SessionThread + let threadVariant: SessionThread.Variant = try? SessionThread .filter(id: interaction.threadId) .select(.variant) .asRequest(of: SessionThread.Variant.self) @@ -421,7 +421,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let webRTCSession: WebRTCSession = self.webRTCSession Storage.shared - .readPublisherFlatMap { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } + .readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in + webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) + } .sinkUntilComplete() } diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index b866fb510..c215c86f0 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -462,18 +462,19 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in Storage.shared - .writePublisherFlatMap { db -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { db -> AnyPublisher in if !updatedMemberIds.contains(userPublicKey) { - return try MessageSender.leave(db, groupPublicKey: threadId) + return MessageSender.leave(db, groupPublicKey: threadId) } - return try MessageSender.update( + return MessageSender.update( db, groupPublicKey: threadId, with: updatedMemberIds, name: updatedName ) } + .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in self?.dismiss(animated: true, completion: nil) // Dismiss the loader diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index c12118742..887fa9b08 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -333,10 +333,9 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in Storage.shared - .writePublisherFlatMap { db in + .writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { result in diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index b8f19816b..bb3ab7fd7 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -158,20 +158,20 @@ extension ContextMenuVC { ) let canCopySessionId: Bool = ( cellViewModel.variant == .standardIncoming && - cellViewModel.threadVariant != .openGroup + cellViewModel.threadVariant != .community ) let canDelete: Bool = ( - cellViewModel.threadVariant != .openGroup || + cellViewModel.threadVariant != .community || currentUserIsOpenGroupModerator || cellViewModel.state == .failed ) let canBan: Bool = ( - cellViewModel.threadVariant == .openGroup && + cellViewModel.threadVariant == .community && currentUserIsOpenGroupModerator ) let shouldShowEmojiActions: Bool = { - if cellViewModel.threadVariant == .openGroup { + if cellViewModel.threadVariant == .community { return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer) } return !currentThreadIsMessageRequest diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 53e6331c3..9ab7b8efd 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -421,7 +421,7 @@ extension ConversationVC: // Send the message Storage.shared - .writePublisher { [weak self] db in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { [weak self] db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } @@ -545,7 +545,7 @@ extension ConversationVC: // Send the message Storage.shared - .writePublisher { [weak self] db in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { [weak self] db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } @@ -1110,9 +1110,9 @@ extension ConversationVC: guard cellViewModel.reactionInfo?.isEmpty == false && ( - self.viewModel.threadData.threadVariant == .legacyClosedGroup || - self.viewModel.threadData.threadVariant == .closedGroup || - self.viewModel.threadData.threadVariant == .openGroup + self.viewModel.threadData.threadVariant == .legacyGroup || + self.viewModel.threadData.threadVariant == .group || + self.viewModel.threadData.threadVariant == .community ), let allMessages: [MessageViewModel] = self.viewModel.interactionData .first(where: { $0.model == .messages })? @@ -1173,10 +1173,10 @@ extension ConversationVC: } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } Storage.shared - .readPublisherFlatMap { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in + .readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in guard let openGroup: OpenGroup = try? OpenGroup .fetchOne(db, id: cellViewModel.threadId), @@ -1185,10 +1185,7 @@ extension ConversationVC: .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db) - else { - return Fail(error: StorageError.objectNotFound) - .eraseToAnyPublisher() - } + else { throw StorageError.objectNotFound } let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager .addPendingReaction( @@ -1267,7 +1264,7 @@ extension ConversationVC: // TODO: Need to test emoji reacts for both open groups and one-to-one to make sure this isn't broken // Perform the sending logic Storage.shared - .writePublisherFlatMap { db -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: cellViewModel.threadId) else { return Just(nil) .setFailureType(to: Error.self) @@ -1360,10 +1357,7 @@ extension ConversationVC: .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db) - else { - return Fail(error: MessageSenderError.invalidMessage) - .eraseToAnyPublisher() - } + else { throw MessageSenderError.invalidMessage } let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager .addPendingReaction( @@ -1552,7 +1546,7 @@ extension ConversationVC: } Storage.shared - .writePublisherFlatMap { db in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupManager.shared.add( db, roomToken: room, @@ -1674,9 +1668,11 @@ extension ConversationVC: // Remote deletion logic func deleteRemotely(from viewController: UIViewController?, request: AnyPublisher, onComplete: (() -> ())?) { // Show a loading indicator - Future { resolver in - ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in - resolver(Result.success(())) + Deferred { + Future { resolver in + ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in + resolver(Result.success(())) + } } } .flatMap { _ in request } @@ -1707,7 +1703,7 @@ extension ConversationVC: // How we delete the message differs depending on the type of thread switch cellViewModel.threadVariant { // Handle open group messages the old way - case .openGroup: + case .community: // If it's an incoming message the user must have moderator status let result: (openGroupServerMessageId: Int64?, openGroup: OpenGroup?)? = Storage.shared.read { db -> (Int64?, OpenGroup?) in ( @@ -1786,7 +1782,7 @@ extension ConversationVC: // Delete the message from the open group deleteRemotely( from: self, - request: Storage.shared.readPublisherFlatMap { db in + request: Storage.shared.readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in OpenGroupAPI.messageDelete( db, id: openGroupServerMessageId, @@ -1800,7 +1796,7 @@ extension ConversationVC: self?.showInputAccessoryView() } - case .contact, .legacyClosedGroup, .closedGroup: + case .contact, .legacyGroup, .group: let serverHash: String? = Storage.shared.read { db -> String? in try Interaction .select(.serverHash) @@ -1859,7 +1855,7 @@ extension ConversationVC: }) actionSheet.addAction(UIAlertAction( - title: (cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup ? + title: (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group ? "delete_message_for_everyone".localized() : String(format: "delete_message_for_me_and_recipient".localized(), threadName) ), @@ -1963,7 +1959,7 @@ extension ConversationVC: } func ban(_ cellViewModel: MessageViewModel) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId let modal: ConfirmationModal = ConfirmationModal( @@ -1975,10 +1971,9 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .readPublisherFlatMap { db -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db -> AnyPublisher in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - return Fail(error: StorageError.objectNotFound) - .eraseToAnyPublisher() + throw StorageError.objectNotFound } return OpenGroupAPI @@ -2020,7 +2015,7 @@ extension ConversationVC: } func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId let modal: ConfirmationModal = ConfirmationModal( @@ -2032,10 +2027,9 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .readPublisherFlatMap { db -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db -> AnyPublisher in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - return Fail(error: StorageError.objectNotFound) - .eraseToAnyPublisher() + throw StorageError.objectNotFound } return OpenGroupAPI @@ -2300,7 +2294,7 @@ extension ConversationVC { } Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in // If we aren't creating a new thread (ie. sending a message request) then send a // messageRequestResponse back to the sender (this allows the sender to know that // they have been approved and can now use this contact in closed groups) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index de43ffd5e..d5aee357d 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -107,7 +107,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { threadId: self.threadId, threadVariant: self.initialThreadVariant, threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()), - currentUserIsClosedGroupMember: ((self.initialThreadVariant != .legacyClosedGroup && self.initialThreadVariant != .closedGroup) ? + currentUserIsClosedGroupMember: ((self.initialThreadVariant != .legacyGroup && self.initialThreadVariant != .group) ? nil : Storage.shared.read { db in GroupMember @@ -342,7 +342,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .read { db -> [MentionInfo] in let userPublicKey: String = getUserHexEncodedPublicKey(db) let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadData.threadVariant != .openGroup ? + let capabilities: Set = (threadData.threadVariant != .community ? nil : try? Capability .select(.variant) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 49f688752..61dd3f5fb 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -306,18 +306,18 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) ) let isGroupThread: Bool = ( - cellViewModel.threadVariant == .openGroup || - cellViewModel.threadVariant == .legacyClosedGroup || - cellViewModel.threadVariant == .closedGroup + cellViewModel.threadVariant == .community || + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group ) - // Profile picture view + // Profile picture view (should always be handled as a standard 'contact' profile picture) profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil) profilePictureView.update( publicKey: cellViewModel.authorId, - threadVariant: cellViewModel.threadVariant, + threadVariant: .contact, // Should always be '.contact' customImageData: nil, profile: cellViewModel.profile, additionalProfile: nil @@ -710,9 +710,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { maxWidth: maxWidth, showingAllReactions: showExpandedReactions, showNumbers: ( - cellViewModel.threadVariant == .legacyClosedGroup || - cellViewModel.threadVariant == .closedGroup || - cellViewModel.threadVariant == .openGroup + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group || + cellViewModel.threadVariant == .community ) ) } @@ -860,7 +860,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { // For open groups only attempt to start a conversation if the author has a blinded id - guard cellViewModel.threadVariant != .openGroup else { + guard cellViewModel.threadVariant != .community else { guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return } delegate?.startThread( @@ -1070,9 +1070,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { case .standardIncoming, .standardIncomingDeleted: let isGroupThread = ( - cellViewModel.threadVariant == .openGroup || - cellViewModel.threadVariant == .legacyClosedGroup || - cellViewModel.threadVariant == .closedGroup + cellViewModel.threadVariant == .community || + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group ) let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 612bfb602..ace9bf25d 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -180,7 +180,7 @@ class ThreadSettingsViewModel: SessionTableViewModel = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() - let threadVariants: [SessionThread.Variant] = [.legacyClosedGroup, .closedGroup] + let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group] let targetRole: GroupMember.Role = GroupMember.Role.standard return SQL(""" @@ -367,12 +367,12 @@ public class HomeViewModel { public func delete(threadId: String, threadVariant: SessionThread.Variant) { Storage.shared.writeAsync { db in switch threadVariant { - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: MessageSender .leave(db, groupPublicKey: threadId) .sinkUntilComplete() - case .openGroup: + case .community: OpenGroupManager.shared.delete(db, openGroupId: threadId) default: break diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index c4cc812a7..98b424f4e 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -445,7 +445,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat return UISwipeActionsConfiguration(actions: [ delete, block ]) - case .legacyClosedGroup, .closedGroup, .openGroup: + case .legacyGroup, .group, .community: return UISwipeActionsConfiguration(actions: [ delete ]) } @@ -469,7 +469,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat let closedGroupThreadIds: [String] = (viewModel.threadData .first { $0.model == .threads }? .elements - .filter { $0.threadVariant == .legacyClosedGroup || $0.threadVariant == .closedGroup } + .filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group } .map { $0.threadId }) .defaulting(to: []) let alertVC: UIAlertController = UIAlertController( diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 33ae824de..058c24486 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -186,12 +186,12 @@ public class MessageRequestsViewModel { ) { _ in Storage.shared.write { db in switch threadVariant { - case .contact, .openGroup: + case .contact, .community: _ = try SessionThread .filter(id: threadId) .deleteAll(db) - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: try ClosedGroup.removeKeysAndUnsubscribe( db, threadId: threadId, diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index 3b563a7dd..4f0495bf8 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -346,17 +346,14 @@ enum GiphyAPI { // URLError codes are negative values return HTTPError.generic } - .flatMap { data, _ -> AnyPublisher<[GiphyImageInfo], Error> in + .tryMap { data, _ -> [GiphyImageInfo] in Logger.error("search request succeeded") guard let imageInfos = self.parseGiphyImages(responseData: data) else { - return Fail(error: HTTPError.invalidResponse) - .eraseToAnyPublisher() + throw HTTPError.invalidResponse } - return Just(imageInfos) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return imageInfos } .eraseToAnyPublisher() } diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 08fdf783b..51205b62f 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -86,26 +86,17 @@ class PhotoCapture: NSObject { return Just(()) .subscribe(on: sessionQueue) .setFailureType(to: Error.self) - .flatMap { [weak self] _ -> AnyPublisher in + .tryMap { [weak self] _ -> Void in self?.session.beginConfiguration() defer { self?.session.commitConfiguration() } - do { - try self?.updateCurrentInput(position: .back) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } + try self?.updateCurrentInput(position: .back) - guard let photoOutput = self?.captureOutput.photoOutput else { - return Fail(error: PhotoCaptureError.initializationFailed) - .eraseToAnyPublisher() - } - - guard self?.session.canAddOutput(photoOutput) == true else { - return Fail(error: PhotoCaptureError.initializationFailed) - .eraseToAnyPublisher() + guard + let photoOutput = self?.captureOutput.photoOutput, + self?.session.canAddOutput(photoOutput) == true + else { + throw PhotoCaptureError.initializationFailed } if let connection = photoOutput.connection(with: .video) { @@ -130,9 +121,7 @@ class PhotoCapture: NSObject { } } - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return () } .handleEvents( receiveCompletion: { [weak self] result in @@ -172,21 +161,12 @@ class PhotoCapture: NSObject { return Just(()) .setFailureType(to: Error.self) .subscribe(on: sessionQueue) - .flatMap { [weak self, newPosition = self.desiredPosition] _ -> AnyPublisher in + .tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in self?.session.beginConfiguration() defer { self?.session.commitConfiguration() } - do { - try self?.updateCurrentInput(position: newPosition) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + try self?.updateCurrentInput(position: newPosition) + return () } .eraseToAnyPublisher() } diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 056514ebd..84fd28be2 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -137,64 +137,68 @@ class PhotoCollectionContents { } private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> { - return Future { [weak self] resolver in - - let options: PHImageRequestOptions = PHImageRequestOptions() - options.isNetworkAccessAllowed = true - - _ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in - - guard let imageData = imageData else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))) - return + return Deferred { + Future { [weak self] resolver in + + let options: PHImageRequestOptions = PHImageRequestOptions() + options.isNetworkAccessAllowed = true + + _ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in + + guard let imageData = imageData else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))) + return + } + + guard let dataUTI = dataUTI else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))) + return + } + + guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))) + return + } + + resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI))) } - - guard let dataUTI = dataUTI else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))) - return - } - - guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))) - return - } - - resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI))) } } .eraseToAnyPublisher() } private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> { - return Future { [weak self] resolver in - - let options: PHVideoRequestOptions = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true - - _ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in - - guard let exportSession = exportSession else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) - return - } - - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") - let exportURL = URL(fileURLWithPath: exportPath) - exportSession.outputURL = exportURL - - Logger.debug("starting video export") - exportSession.exportAsynchronously { - Logger.debug("Completed video export") - - guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))) + return Deferred { + Future { [weak self] resolver in + + let options: PHVideoRequestOptions = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + + _ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in + + guard let exportSession = exportSession else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) return } - - resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))) + + exportSession.outputFileType = AVFileType.mp4 + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + + let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") + let exportURL = URL(fileURLWithPath: exportPath) + exportSession.outputURL = exportURL + + Logger.debug("starting video export") + exportSession.exportAsynchronously { + Logger.debug("Completed video export") + + guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))) + return + } + + resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))) + } } } } diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 88abcbd3f..79575da2f 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -88,7 +88,7 @@ let kAudioNotificationsThrottleInterval: TimeInterval = 5 protocol NotificationPresenterAdaptee: AnyObject { - func registerNotificationSettings() -> Future + func registerNotificationSettings() -> AnyPublisher func notify( category: AppNotificationCategory, @@ -150,7 +150,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { func registerNotificationSettings() -> AnyPublisher { return adaptee.registerNotificationSettings() - .eraseToAnyPublisher() } public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { @@ -163,7 +162,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Try to group notifications for interactions from open groups let identifier: String = interaction.notificationIdentifier( - shouldGroupMessagesForThread: (thread.variant == .openGroup) + shouldGroupMessagesForThread: (thread.variant == .community) ) // While batch processing, some of the necessary changes have not been commited. @@ -203,7 +202,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { case .contact: notificationTitle = (isMessageRequest ? "Session" : senderName) - case .legacyClosedGroup, .closedGroup, .openGroup: + case .legacyGroup, .group, .community: notificationTitle = String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, @@ -275,9 +274,9 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard - thread.variant != .legacyClosedGroup && - thread.variant != .closedGroup && - thread.variant != .openGroup + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community else { return } guard interaction.variant == .infoCall, @@ -347,9 +346,9 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard - thread.variant != .legacyClosedGroup && - thread.variant != .closedGroup && - thread.variant != .openGroup + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community else { return } guard !isMessageRequest else { return } @@ -539,7 +538,7 @@ class NotificationActionHandler { } return Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: DispatchQueue.main) { db in let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), @@ -607,7 +606,7 @@ class NotificationActionHandler { private func markAsRead(thread: SessionThread) -> AnyPublisher { return Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in try Interaction.markAsRead( db, interactionId: try thread.interactions diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index f1ff01c69..aa3c3c2d1 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -54,10 +54,9 @@ public enum PushRegistrationError: Error { return registerUserNotificationSettings() .setFailureType(to: Error.self) .receive(on: DispatchQueue.main) // MUST be on main thread - .flatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in + .tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in #if targetEnvironment(simulator) - return Fail(error: PushRegistrationError.pushNotSupported(description: "Push not supported on simulators")) - .eraseToAnyPublisher() + throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators") #endif return self.registerForVanillaPushToken() @@ -101,7 +100,6 @@ public enum PushRegistrationError: Error { public func registerUserNotificationSettings() -> AnyPublisher { AssertIsOnMainThread() return notificationPresenter.registerNotificationSettings() - .eraseToAnyPublisher() } /** @@ -142,8 +140,10 @@ public enum PushRegistrationError: Error { UIApplication.shared.registerForRemoteNotifications() // No pending vanilla token yet; create a new publisher - let publisher: AnyPublisher = Future { self.vanillaTokenResolver = $0 } - .eraseToAnyPublisher() + let publisher: AnyPublisher = Deferred { + Future { self.vanillaTokenResolver = $0 } + } + .eraseToAnyPublisher() self.vanillaTokenPublisher = publisher return publisher @@ -238,8 +238,10 @@ public enum PushRegistrationError: Error { } // No pending voip token yet. Create a new publisher - let publisher: AnyPublisher = Future { self.voipTokenResolver = $0 } - .eraseToAnyPublisher() + let publisher: AnyPublisher = Deferred { + Future { self.voipTokenResolver = $0 } + } + .eraseToAnyPublisher() self.voipTokenPublisher = publisher return publisher diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 4aa25e4b3..ba51ed243 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -73,14 +73,16 @@ public enum SyncPushTokensJob: JobExecutor { .eraseToAnyPublisher() } - return Future { resolver in - SyncPushTokensJob.registerForPushNotifications( - pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: shouldUploadTokens, - success: { resolver(Result.success(())) }, - failure: { resolver(Result.failure($0)) } - ) + return Deferred { + Future { resolver in + SyncPushTokensJob.registerForPushNotifications( + pushToken: pushToken, + voipToken: voipToken, + isForcedUpdate: shouldUploadTokens, + success: { resolver(Result.success(())) }, + failure: { resolver(Result.failure($0)) } + ) + } } .handleEvents( receiveCompletion: { result in diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 525edc592..3af0cccd3 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -73,25 +73,27 @@ class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelega } extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { - func registerNotificationSettings() -> Future { - return Future { [weak self] resolver in - self?.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in - self?.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories) - - if granted {} - else if let error: Error = error { - Logger.error("failed with error: \(error)") + func registerNotificationSettings() -> AnyPublisher { + return Deferred { + Future { [weak self] resolver in + self?.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in + self?.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories) + + if granted {} + else if let error: Error = error { + Logger.error("failed with error: \(error)") + } + else { + Logger.error("failed without error.") + } + + // Note that the promise is fulfilled regardless of if notification permssions were + // granted. This promise only indicates that the user has responded, so we can + // proceed with requesting push tokens and complete registration. + resolver(Result.success(())) } - else { - Logger.error("failed without error.") - } - - // Note that the promise is fulfilled regardless of if notification permssions were - // granted. This promise only indicates that the user has responded, so we can - // proceed with requesting push tokens and complete registration. - resolver(Result.success(())) } - } + }.eraseToAnyPublisher() } func notify( @@ -114,7 +116,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier) let shouldGroupNotification: Bool = ( - threadVariant == .openGroup && + threadVariant == .community && replacingIdentifier == threadIdentifier ) let isAppActive = UIApplication.shared.applicationState == .active diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 948893aeb..acdf75b68 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -23,11 +23,8 @@ enum Onboarding { return Atomic( SnodeAPI.getSwarm(for: userPublicKey) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { swarm -> AnyPublisher in - guard let snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher in + guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } return CurrentUserPoller.poll( namespaces: [.configUserProfile], @@ -41,7 +38,7 @@ enum Onboarding { ) } .flatMap { _ -> AnyPublisher in - Storage.shared.readPublisher { db in + Storage.shared.readPublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in try Profile .filter(id: userPublicKey) .select(.name) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index aa8c15340..e204a2005 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -169,7 +169,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in Storage.shared - .writePublisherFlatMap { db in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupManager.shared.add( db, roomToken: roomToken, @@ -194,7 +194,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC if shouldOpenCommunity { SessionApp.presentConversation( for: OpenGroup.idFor(roomToken: roomToken, server: server), - threadVariant: .openGroup, + threadVariant: .community, isMessageRequest: false, action: .compose, focusInteractionInfo: nil, diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index eb12b2e66..706df79f4 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -322,7 +322,7 @@ extension OpenGroupSuggestionGrid { Publishers .MergeMany( Storage.shared - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupManager .roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer) } diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 64aab5884..b93e0fcc4 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -150,7 +150,7 @@ class HelpViewModel: SessionTableViewModel 0) && ( - cellViewModel.threadVariant == .legacyClosedGroup || - cellViewModel.threadVariant == .closedGroup || - cellViewModel.threadVariant == .openGroup + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group || + cellViewModel.threadVariant == .community ) ) profilePictureView.update( @@ -514,7 +514,7 @@ public final class FullConversationCell: UITableViewCell { )) } - if cellViewModel.threadVariant == .legacyClosedGroup || cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + if cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community { let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) result.append(NSAttributedString( diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 5d4a6adf6..b5391a23a 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -67,11 +67,8 @@ public final class BackgroundPoller { return SnodeAPI.getSwarm(for: userPublicKey) .subscribeOnMain(immediately: true) .receiveOnMain(immediately: true) - .flatMap { swarm -> AnyPublisher in - guard let snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher in + guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } return CurrentUserPoller.poll( namespaces: CurrentUserPoller.namespaces, @@ -104,10 +101,9 @@ public final class BackgroundPoller { SnodeAPI.getSwarm(for: groupPublicKey) .subscribeOnMain(immediately: true) .receiveOnMain(immediately: true) - .flatMap { swarm -> AnyPublisher in + .tryFlatMap { swarm -> AnyPublisher in guard let snode: Snode = swarm.randomElement() else { - return Fail(error: OnionRequestAPIError.insufficientSnodes) - .eraseToAnyPublisher() + throw OnionRequestAPIError.insufficientSnodes } return ClosedGroupPoller.poll( diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 3bea51b61..4e8b92997 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -241,7 +241,7 @@ enum MockDataGenerator { } let thread: SessionThread = try! SessionThread - .fetchOrCreate(db, id: randomGroupPublicKey, variant: .legacyClosedGroup) + .fetchOrCreate(db, id: randomGroupPublicKey, variant: .legacyGroup) .with(shouldBeVisible: true) .saved(db) _ = try! ClosedGroup( @@ -367,7 +367,7 @@ enum MockDataGenerator { // Create the open group model and the thread let thread: SessionThread = try! SessionThread - .fetchOrCreate(db, id: randomGroupPublicKey, variant: .openGroup) + .fetchOrCreate(db, id: randomGroupPublicKey, variant: .community) .with(shouldBeVisible: true) .saved(db) _ = try! OpenGroup( diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 2d0fcafa3..4b32ea07e 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -163,48 +163,50 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { .eraseToAnyPublisher() } - return Future { [weak self] resolver in - self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in - if let error = error { - return - } - - guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else { - preconditionFailure() - } - - self?.peerConnection?.setLocalDescription(sdp) { error in + return Deferred { + Future { [weak self] resolver in + self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in if let error = error { - print("Couldn't initiate call due to error: \(error).") - resolver(Result.failure(error)) return } - } - - Storage.shared - .writePublisher { db in - try MessageSender - .preparedSendData( - db, - message: CallMessage( - uuid: uuid, - kind: .offer, - sdps: [ sdp.sdp ], - sentTimestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()) - ), - to: try Message.Destination.from(db, thread: thread), - interactionId: nil - ) + + guard let sdp: RTCSessionDescription = self?.correctSessionDescription(sdp: sdp) else { + preconditionFailure() } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: resolver(Result.success(())) - case .failure(let error): resolver(Result.failure(error)) - } + + self?.peerConnection?.setLocalDescription(sdp) { error in + if let error = error { + print("Couldn't initiate call due to error: \(error).") + resolver(Result.failure(error)) + return } - ) + } + + Storage.shared + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in + try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: uuid, + kind: .offer, + sdps: [ sdp.sdp ], + sentTimestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()) + ), + to: try Message.Destination.from(db, thread: thread), + interactionId: nil + ) + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: resolver(Result.success(())) + case .failure(let error): resolver(Result.failure(error)) + } + } + ) + } } } .eraseToAnyPublisher() @@ -216,10 +218,9 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return Storage.shared - .readPublisherFlatMap { db -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher in guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { - return Fail(error: WebRTCSessionError.noThread) - .eraseToAnyPublisher() + throw WebRTCSessionError.noThread } return Just(thread) @@ -246,7 +247,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in try MessageSender .preparedSendData( db, @@ -293,10 +294,9 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { self.queuedICECandidates.removeAll() Storage.shared - .writePublisherFlatMap { db in + .writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { - return Fail(error: WebRTCSessionError.noThread) - .eraseToAnyPublisher() + throw WebRTCSessionError.noThread } SNLog("[Calls] Batch sending \(candidates.count) ICE candidates.") diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index fc84b96b0..73141b519 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -565,7 +565,7 @@ enum _003_YDBToGRDBMigration: Migration { switch legacyThread { case let groupThread as SMKLegacy._GroupThread: - threadVariant = (groupThread.isOpenGroup ? .openGroup : .legacyClosedGroup) + threadVariant = (groupThread.isOpenGroup ? .community : .legacyGroup) onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions default: diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index fc8ef5723..e9c3de4e0 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -1036,7 +1036,7 @@ extension Attachment { let attachmentId: String = self.id return Storage.shared - .writePublisherFlatMap { db -> AnyPublisher<(String?, Data?, Data?), Error> in + .writePublisherFlatMap(receiveOn: queue) { db -> AnyPublisher<(String?, Data?, Data?), Error> in // 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 @@ -1068,8 +1068,7 @@ extension Attachment { if destination.shouldEncrypt { guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { SNLog("Couldn't encrypt attachment.") - return Fail(error: AttachmentError.encryptionFailed) - .eraseToAnyPublisher() + throw AttachmentError.encryptionFailed } data = ciphertext @@ -1077,10 +1076,7 @@ extension Attachment { // Check the file size SNLog("File size: \(data.count) bytes.") - if data.count > FileServerAPI.maxFileSize { - return Fail(error: HTTPError.maxFileSizeExceeded) - .eraseToAnyPublisher() - } + if data.count > FileServerAPI.maxFileSize { throw HTTPError.maxFileSizeExceeded } // Update the attachment to the 'uploading' state _ = try? Attachment @@ -1131,13 +1127,14 @@ extension Attachment { .eraseToAnyPublisher() } } + .receive(on: queue) .flatMap { fileId, encryptionKey, digest -> AnyPublisher in /// Save the final upload info /// /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is /// updated correctly Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: queue) { db in try self .with( serverId: fileId, diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index fab33fca5..0d8585fd0 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -353,7 +353,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu state: .sending ).insert(db) - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: let closedGroupMemberIds: Set = (try? GroupMember .select(.profileId) .filter(GroupMember.Columns.groupId == threadId) @@ -379,7 +379,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ).insert(db) } - case .openGroup: + case .community: // Since we use the 'RecipientState' type to manage the message state // we need to ensure we have a state for all threads; so for open groups // we just use the open group id as the 'recipientId' value diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index fba228a70..35018ce59 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -318,17 +318,12 @@ public extension LinkPreview { .flatMap { data, response in parseLinkDataAndBuildDraft(linkData: data, response: response, linkUrlString: previewUrl) } - .flatMap { linkPreviewDraft -> AnyPublisher in - guard linkPreviewDraft.isValid() else { - return Fail(error: LinkPreviewError.noPreview) - .eraseToAnyPublisher() - } + .tryMap { linkPreviewDraft -> LinkPreviewDraft in + guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl) - return Just(linkPreviewDraft) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return linkPreviewDraft } .eraseToAnyPublisher() } @@ -362,25 +357,18 @@ public extension LinkPreview { .dataTaskPublisher(for: request) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .mapError { _ -> Error in HTTPError.generic } // URLError codes are negative values - .flatMap { data, response -> AnyPublisher<(Data, URLResponse), Error> in + .tryMap { data, response -> (Data, URLResponse) in guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { - return Fail(error: LinkPreviewError.assertionFailure) - .eraseToAnyPublisher() + throw LinkPreviewError.assertionFailure } if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { guard contentType.lowercased().hasPrefix("text/") else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() + throw LinkPreviewError.invalidContent } } - guard data.count > 0 else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } + guard data.count > 0 else { throw LinkPreviewError.invalidContent } - return Just((data, response)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return (data, response) } .catch { error -> AnyPublisher<(Data, URLResponse), Error> in guard isRetryable(error: error), remainingRetries > 0 else { @@ -496,63 +484,44 @@ public extension LinkPreview { priority: .high, shouldIgnoreSignalProxy: true ) - .flatMap { asset, _ -> AnyPublisher in - do { - let imageSize = NSData.imageSize(forFilePath: asset.filePath, mimeType: imageMimeType) - - guard imageSize.width > 0, imageSize.height > 0 else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } - - let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) - - guard let srcImage = UIImage(data: data) else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } - - // Loki: If it's a GIF then ensure its validity and don't download it as a JPG - if - imageMimeType == OWSMimeTypeImageGif && - NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif) - { - return Just(data) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - let maxImageSize: CGFloat = 1024 - let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize - - guard shouldResize else { - guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } - - return Just(dstData) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - guard let dstImage = srcImage.resized(withMaxDimensionPoints: maxImageSize) else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } - guard let dstData = dstImage.jpegData(compressionQuality: 0.8) else { - return Fail(error: LinkPreviewError.invalidContent) - .eraseToAnyPublisher() - } - - return Just(dstData) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + .tryMap { asset, _ -> Data in + let imageSize = NSData.imageSize(forFilePath: asset.filePath, mimeType: imageMimeType) + + guard imageSize.width > 0, imageSize.height > 0 else { + throw LinkPreviewError.invalidContent } - catch { - return Fail(error: LinkPreviewError.assertionFailure) - .eraseToAnyPublisher() + + guard let data: Data = try? Data(contentsOf: URL(fileURLWithPath: asset.filePath)) else { + throw LinkPreviewError.assertionFailure } + + guard let srcImage = UIImage(data: data) else { throw LinkPreviewError.invalidContent } + + // Loki: If it's a GIF then ensure its validity and don't download it as a JPG + if + imageMimeType == OWSMimeTypeImageGif && + NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif) + { + return data + } + + let maxImageSize: CGFloat = 1024 + let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize + + guard shouldResize else { + guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { + throw LinkPreviewError.invalidContent + } + + return dstData + } + + guard + let dstImage = srcImage.resized(withMaxDimensionPoints: maxImageSize), + let dstData = dstImage.jpegData(compressionQuality: 0.8) + else { throw LinkPreviewError.invalidContent } + + return dstData } .mapError { _ -> Error in LinkPreviewError.couldNotDownload } .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 737d78cd6..4b73b0d2d 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -324,9 +324,9 @@ public extension Profile { } switch threadVariant { - case .contact, .legacyClosedGroup, .closedGroup: return name + case .contact, .legacyGroup, .group: return name - case .openGroup: + case .community: // In open groups, where it's more likely that multiple users have the same name, // we display a bit of the Session ID after a user's display name for added context return "\(name) (\(Profile.truncated(id: id, truncating: .middle)))" diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index b5ddd28be..bd3820816 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -37,9 +37,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { case contact - case legacyClosedGroup - case openGroup - case closedGroup + case legacyGroup + case community + case group } /// Unique identifier for a thread (formerly known as uniqueId) @@ -312,8 +312,8 @@ public extension SessionThread { profile: Profile? = nil ) -> String { switch variant { - case .legacyClosedGroup, .closedGroup: return (closedGroupName ?? "Unknown Group") - case .openGroup: return (openGroupName ?? "Unknown Group") + case .legacyGroup, .group: return (closedGroupName ?? "Unknown Group") + case .community: return (openGroupName ?? "Unknown Community") case .contact: guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } guard let profile: Profile = profile else { @@ -329,7 +329,7 @@ public extension SessionThread { threadVariant: Variant ) -> String? { guard - threadVariant == .openGroup, + threadVariant == .community, let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?) = Storage.shared.read({ db in return ( Identity.fetchUserEd25519KeyPair(db), diff --git a/SessionMessagingKit/Database/Models/SharedConfigDump.swift b/SessionMessagingKit/Database/Models/SharedConfigDump.swift index 5b424eb8d..db2fae388 100644 --- a/SessionMessagingKit/Database/Models/SharedConfigDump.swift +++ b/SessionMessagingKit/Database/Models/SharedConfigDump.swift @@ -20,7 +20,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist case userProfile case contacts case convoInfoVolatile - case groups + case userGroups } /// The type of config this dump is for @@ -66,14 +66,14 @@ public extension ConfigDump { } public extension ConfigDump.Variant { - static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts, .convoInfoVolatile, .groups ] + static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts, .convoInfoVolatile, .userGroups ] var configMessageKind: SharedConfigMessage.Kind { switch self { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } } @@ -82,7 +82,7 @@ public extension ConfigDump.Variant { case .userProfile: return SnodeAPI.Namespace.configUserProfile case .contacts: return SnodeAPI.Namespace.configContacts case .convoInfoVolatile: return SnodeAPI.Namespace.configConvoInfoVolatile - case .groups: return SnodeAPI.Namespace.configGroups + case .userGroups: return SnodeAPI.Namespace.configUserGroups } } } diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 0676547e1..15d9ec9c6 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -79,15 +79,10 @@ public enum FileServerAPI { return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, with: serverPublicKey, timeout: FileServerAPI.fileTimeout) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { _, response -> AnyPublisher in - guard let response: Data = response else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + .tryMap { _, response -> Data in + guard let response: Data = response else { throw HTTPError.parsingFailed } - return Just(response) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return response } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 47fe9e0c6..471ce896f 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -87,17 +87,14 @@ public enum AttachmentDownloadJob: JobExecutor { Just(attachment.downloadUrl) .setFailureType(to: Error.self) - .flatMap { maybeDownloadUrl -> AnyPublisher in + .tryFlatMap { maybeDownloadUrl -> AnyPublisher in guard let downloadUrl: String = maybeDownloadUrl, let fileId: String = Attachment.fileId(for: downloadUrl) - else { - return Fail(error: AttachmentDownloadError.invalidUrl) - .eraseToAnyPublisher() - } + else { throw AttachmentDownloadError.invalidUrl } return Storage.shared - .readPublisher { db in try OpenGroup.fetchOne(db, id: threadId) } + .readPublisher(receiveOn: queue) { db in try OpenGroup.fetchOne(db, id: threadId) } .flatMap { maybeOpenGroup -> AnyPublisher in guard let openGroup: OpenGroup = maybeOpenGroup else { return FileServerAPI @@ -109,7 +106,7 @@ public enum AttachmentDownloadJob: JobExecutor { } return Storage.shared - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: queue) { db in OpenGroupAPI .downloadFile( db, @@ -123,41 +120,34 @@ public enum AttachmentDownloadJob: JobExecutor { } .eraseToAnyPublisher() } - .flatMap { data -> AnyPublisher in - do { - // Store the encrypted data temporarily - try data.write(to: temporaryFileUrl, options: .atomic) - - // Decrypt the data - let plaintext: Data = try { - guard - let key: Data = attachment.encryptionKey, - let digest: Data = attachment.digest, - key.count > 0, - digest.count > 0 - else { return data } // Open group attachments are unencrypted - - return try Cryptography.decryptAttachment( - data, - withKey: key, - digest: digest, - unpaddedSize: UInt32(attachment.byteCount) - ) - }() - - // Write the data to disk - guard try attachment.write(data: plaintext) else { - throw AttachmentDownloadError.failedToSaveFile - } - - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() + .receive(on: queue) + .tryMap { data -> Void in + // Store the encrypted data temporarily + try data.write(to: temporaryFileUrl, options: .atomic) + + // Decrypt the data + let plaintext: Data = try { + guard + let key: Data = attachment.encryptionKey, + let digest: Data = attachment.digest, + key.count > 0, + digest.count > 0 + else { return data } // Open group attachments are unencrypted + + return try Cryptography.decryptAttachment( + data, + withKey: key, + digest: digest, + unpaddedSize: UInt32(attachment.byteCount) + ) + }() + + // Write the data to disk + guard try attachment.write(data: plaintext) else { + throw AttachmentDownloadError.failedToSaveFile } + + return () } .sinkUntilComplete( receiveCompletion: { result in diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index 2fbe245dd..d5ba2baad 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -39,18 +39,7 @@ public enum ConfigurationSyncJob: JobExecutor { // fresh install due to the migrations getting run) guard let pendingSwarmConfigChanges: [SingleDestinationChanges] = Storage.shared - .read({ db -> [SessionUtil.OutgoingConfResult]? in - guard - Identity.userExists(db), - let ed25519SecretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey - else { return nil } - - return try SessionUtil.pendingChanges( - db, - userPublicKey: getUserHexEncodedPublicKey(db), - ed25519SecretKey: ed25519SecretKey - ) - })? + .read({ db in try SessionUtil.pendingChanges(db) })? .grouped(by: { $0.destination }) .map({ (destination: Message.Destination, value: [SessionUtil.OutgoingConfResult]) -> SingleDestinationChanges in SingleDestinationChanges( @@ -75,7 +64,7 @@ public enum ConfigurationSyncJob: JobExecutor { } Storage.shared - .readPublisher { db in + .readPublisher(receiveOn: queue) { db in try pendingSwarmConfigChanges .map { (change: SingleDestinationChanges) -> (messages: [TargetedMessage], allOldHashes: Set) in ( @@ -96,8 +85,6 @@ public enum ConfigurationSyncJob: JobExecutor { ) } } - .subscribe(on: queue) - .receive(on: queue) .flatMap { (pendingSwarmChange: [(messages: [TargetedMessage], allOldHashes: Set)]) -> AnyPublisher<[HTTP.BatchResponse], Error> in Publishers .MergeMany( @@ -119,17 +106,17 @@ public enum ConfigurationSyncJob: JobExecutor { .collect() .eraseToAnyPublisher() } - .flatMap { (responses: [HTTP.BatchResponse]) -> AnyPublisher<[SuccessfulChange], Error> in + .receive(on: queue) + .tryMap { (responses: [HTTP.BatchResponse]) -> [SuccessfulChange] in // We make a sequence call for this so it's possible to get fewer responses than // expected so if that happens fail and re-run later guard responses.count == pendingSwarmConfigChanges.count else { - return Fail(error: HTTPError.invalidResponse) - .eraseToAnyPublisher() + throw HTTPError.invalidResponse } // Process the response data into an easy to understand for (this isn't strictly // needed but the code gets convoluted without this) - let successfulChanges: [SuccessfulChange] = zip(responses, pendingSwarmConfigChanges) + return zip(responses, pendingSwarmConfigChanges) .compactMap { (batchResponse: HTTP.BatchResponse, pendingSwarmChange: SingleDestinationChanges) -> [SuccessfulChange]? in let maybePublicKey: String? = { switch pendingSwarmChange.destination { @@ -179,10 +166,6 @@ public enum ConfigurationSyncJob: JobExecutor { } } .flatMap { $0 } - - return Just(successfulChanges) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } .map { (successfulChanges: [SuccessfulChange]) -> [ConfigDump] in // Now that we have the successful changes, we need to mark them as pushed and @@ -213,8 +196,7 @@ public enum ConfigurationSyncJob: JobExecutor { receiveCompletion: { result in switch result { case .finished: break - case .failure(let error): - failure(job, error, false) + case .failure(let error): failure(job, error, false) } }, receiveValue: { (configDumps: [ConfigDump]) in @@ -354,7 +336,7 @@ public extension ConfigurationSyncJob { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard Features.useSharedUtilForUserConfig else { return Storage.shared - .writePublisher { db -> MessageSender.PreparedSendData in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> MessageSender.PreparedSendData in // If we don't have a userKeyPair yet then there is no need to sync the configuration // as the user doesn't exist yet (this will get triggered on the first launch of a // fresh install due to the migrations getting run) @@ -369,21 +351,21 @@ public extension ConfigurationSyncJob { interactionId: nil ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.global(qos: .userInitiated)) .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } .eraseToAnyPublisher() } // Trigger the job emitting the result when completed - return Future { resolver in - ConfigurationSyncJob.run( - Job(variant: .configurationSync), - queue: DispatchQueue.global(qos: .userInitiated), - success: { _, _ in resolver(Result.success(())) }, - failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, - deferred: { _ in } - ) + return Deferred { + Future { resolver in + ConfigurationSyncJob.run( + Job(variant: .configurationSync), + queue: DispatchQueue.global(qos: .userInitiated), + success: { _, _ in resolver(Result.success(())) }, + failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, + deferred: { _ in } + ) + } } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 99d47161b..9724921b4 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -84,7 +84,7 @@ public enum GarbageCollectionJob: JobExecutor { SELECT \(interaction.alias[Column.rowID]) FROM \(Interaction.self) JOIN \(SessionThread.self) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(thread[.id]) = \(interaction[.threadId]) ) JOIN ( diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 27c234908..f71a8005a 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -165,7 +165,7 @@ 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 Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: queue) { db in try MessageSender.preparedSendData( db, message: details.message, @@ -173,9 +173,9 @@ public enum MessageSendJob: JobExecutor { interactionId: job.interactionId ) } - .subscribe(on: queue) .map { sendData in sendData.with(fileIds: messageFileIds) } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .receive(on: queue) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 0bbbc6bc8..b8b99052a 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -36,7 +36,7 @@ public enum SendReadReceiptsJob: JobExecutor { } Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: queue) { db in try MessageSender.preparedSendData( db, message: ReadReceipt( @@ -46,7 +46,6 @@ public enum SendReadReceiptsJob: JobExecutor { interactionId: nil ) } - .subscribe(on: queue) .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } .receive(on: queue) .sinkUntilComplete( @@ -120,9 +119,9 @@ public extension SendReadReceiptsJob { .joining( // Don't send read receipts in group threads required: Interaction.thread - .filter(SessionThread.Columns.variant != SessionThread.Variant.legacyClosedGroup) - .filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup) - .filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup) + .filter(SessionThread.Columns.variant != SessionThread.Variant.legacyGroup) + .filter(SessionThread.Columns.variant != SessionThread.Variant.group) + .filter(SessionThread.Columns.variant != SessionThread.Variant.community) ) .distinct() ) diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift index ae84db707..09f85da72 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -42,14 +42,17 @@ internal extension SessionUtil { isBlocked: contact.blocked, didApproveMe: contact.approved_me ) + let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true) let profileResult: Profile = Profile( id: contactId, - name: (contact.name.map { String(cString: $0) } ?? ""), - nickname: contact.nickname.map { String(cString: $0) }, - profilePictureUrl: contact.profile_pic.url.map { String(cString: $0) }, - profileEncryptionKey: (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ? - Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) : - nil + name: (String(libSessionVal: contact.name) ?? ""), + nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true), + profilePictureUrl: profilePictureUrl, + profileEncryptionKey: (profilePictureUrl == nil ? nil : + Data( + libSessionVal: contact.profile_pic.key, + count: ProfileManager.avatarAES256KeyByteLength + ) ) ) @@ -165,9 +168,7 @@ internal extension SessionUtil { // Update the name targetContacts .forEach { (id, maybeContact, maybeProfile) in - var sessionId: [CChar] = id - .bytes - .map { CChar(bitPattern: $0) } + var sessionId: [CChar] = id.cArray var contact: contacts_contact = contacts_contact() guard contacts_get_or_construct(conf, &contact, &sessionId) else { SNLog("Unable to upsert contact from Config Message") @@ -179,60 +180,33 @@ internal extension SessionUtil { contact.approved = updatedContact.isApproved contact.approved_me = updatedContact.didApproveMe contact.blocked = updatedContact.isBlocked + + // Store the updated contact (needs to happen before variables go out of scope) + contacts_set(conf, &contact) } - // Update the profile data (if there is one) + // Update the profile data (if there is one - users we have sent a message request to may + // not have profile info in certain situations) if let updatedProfile: Profile = maybeProfile { - /// Users we have sent a message request to may not have profile info in certain situations - /// - /// Note: We **MUST** store these in local variables rather than access them directly or they won't - /// exist in memory long enough to actually be assigned in the C type - let updatedName: [CChar]? = (updatedProfile.name.isEmpty ? - nil : - updatedProfile.name - .bytes - .map { CChar(bitPattern: $0) } + let oldAvatarUrl: String? = String(libSessionVal: contact.profile_pic.url) + let oldAvatarKey: Data? = Data( + libSessionVal: contact.profile_pic.key, + count: ProfileManager.avatarAES256KeyByteLength ) - let updatedNickname: [CChar]? = updatedProfile.nickname? - .bytes - .map { CChar(bitPattern: $0) } - let updatedAvatarUrl: [CChar]? = updatedProfile.profilePictureUrl? - .bytes - .map { CChar(bitPattern: $0) } - let updatedAvatarKey: [UInt8]? = updatedProfile.profileEncryptionKey? - .bytes - let oldAvatarUrl: String? = contact.profile_pic.url.map { String(cString: $0) } - let oldAvatarKey: Data? = (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ? - Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) : - nil - ) - updatedName?.withUnsafeBufferPointer { contact.name = $0.baseAddress } - (updatedNickname == nil ? - contact.nickname = nil : - updatedNickname?.withUnsafeBufferPointer { contact.nickname = $0.baseAddress } - ) - (updatedAvatarUrl == nil ? - contact.profile_pic.url = nil : - updatedAvatarUrl?.withUnsafeBufferPointer { - contact.profile_pic.url = $0.baseAddress - } - ) - (updatedAvatarKey == nil ? - contact.profile_pic.key = nil : - updatedAvatarKey?.withUnsafeBufferPointer { - contact.profile_pic.key = $0.baseAddress - } - ) - contact.profile_pic.keylen = (updatedAvatarKey?.count ?? 0) + + contact.name = updatedProfile.name.toLibSession() + contact.nickname = updatedProfile.nickname.toLibSession() + contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession() + contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession() // Download the profile picture if needed if oldAvatarUrl != updatedProfile.profilePictureUrl || oldAvatarKey != updatedProfile.profileEncryptionKey { ProfileManager.downloadAvatar(for: updatedProfile) } + + // Store the updated contact (needs to happen before variables go out of scope) + contacts_set(conf, &contact) } - - // Store the updated contact - contacts_set(conf, &contact) } return ConfResult( diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift index 12a0602e6..7c6d9facd 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift @@ -21,8 +21,8 @@ internal extension SessionUtil { let volatileThreadInfo: [VolatileThreadInfo] = atomicConf.mutate { conf -> [VolatileThreadInfo] in var volatileThreadInfo: [VolatileThreadInfo] = [] var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() - var openGroup: convo_info_volatile_open = convo_info_volatile_open() - var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + var community: convo_info_volatile_community = convo_info_volatile_community() + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf) while !convo_info_volatile_iterator_done(convoIterator) { @@ -43,23 +43,23 @@ internal extension SessionUtil { ) ) } - else if convo_info_volatile_it_is_open(convoIterator, &openGroup) { - let server: String = String(cString: withUnsafeBytes(of: openGroup.base_url) { [UInt8]($0) } + else if convo_info_volatile_it_is_community(convoIterator, &community) { + let server: String = String(cString: withUnsafeBytes(of: community.base_url) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() ) - let roomToken: String = String(cString: withUnsafeBytes(of: openGroup.room) { [UInt8]($0) } + let roomToken: String = String(cString: withUnsafeBytes(of: community.room) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() ) - let publicKey: String = withUnsafePointer(to: openGroup.pubkey, { pubkeyBytes in + let publicKey: String = withUnsafePointer(to: community.pubkey, { pubkeyBytes in Data(bytes: pubkeyBytes, count: 32).toHexString() }) volatileThreadInfo.append( VolatileThreadInfo( threadId: OpenGroup.idFor(roomToken: roomToken, server: server), - variant: .openGroup, + variant: .community, openGroupUrlInfo: VolatileThreadInfo.OpenGroupUrlInfo( threadId: OpenGroup.idFor(roomToken: roomToken, server: server), server: server, @@ -67,14 +67,14 @@ internal extension SessionUtil { publicKey: publicKey ), changes: [ - .markedAsUnread(openGroup.unread), - .lastReadTimestampMs(openGroup.last_read) + .markedAsUnread(community.unread), + .lastReadTimestampMs(community.last_read) ] ) ) } - else if convo_info_volatile_it_is_legacy_closed(convoIterator, &legacyClosedGroup) { - let groupId: String = String(cString: withUnsafeBytes(of: legacyClosedGroup.group_id) { [UInt8]($0) } + else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) { + let groupId: String = String(cString: withUnsafeBytes(of: legacyGroup.group_id) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() ) @@ -82,10 +82,10 @@ internal extension SessionUtil { volatileThreadInfo.append( VolatileThreadInfo( threadId: groupId, - variant: .legacyClosedGroup, + variant: .legacyGroup, changes: [ - .markedAsUnread(legacyClosedGroup.unread), - .lastReadTimestampMs(legacyClosedGroup.last_read) + .markedAsUnread(legacyGroup.unread), + .lastReadTimestampMs(legacyGroup.last_read) ] ) ) @@ -183,11 +183,13 @@ internal extension SessionUtil { convoInfoVolatileChanges: [VolatileThreadInfo], in atomicConf: Atomic?> ) throws -> ConfResult { + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + // Since we are doing direct memory manipulation we are using an `Atomic` type which has // blocking access in it's `mutate` closure return atomicConf.mutate { conf in convoInfoVolatileChanges.forEach { threadInfo in - var cThreadId: [CChar] = threadInfo.cThreadId + var cThreadId: [CChar] = threadInfo.threadId.cArray switch threadInfo.variant { case .contact: @@ -209,10 +211,10 @@ internal extension SessionUtil { } convo_info_volatile_set_1to1(conf, &oneToOne) - case .legacyClosedGroup: - var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + case .legacyGroup: + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() - guard convo_info_volatile_get_or_construct_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else { + guard convo_info_volatile_get_or_construct_legacy_group(conf, &legacyGroup, &cThreadId) else { SNLog("Unable to create legacy group conversation when updating last read timestamp") return } @@ -220,27 +222,27 @@ internal extension SessionUtil { threadInfo.changes.forEach { change in switch change { case .lastReadTimestampMs(let lastReadMs): - legacyClosedGroup.last_read = lastReadMs + legacyGroup.last_read = lastReadMs case .markedAsUnread(let unread): - legacyClosedGroup.unread = unread + legacyGroup.unread = unread } } - convo_info_volatile_set_legacy_closed(conf, &legacyClosedGroup) + convo_info_volatile_set_legacy_group(conf, &legacyGroup) - case .openGroup: + case .community: guard - var cBaseUrl: [CChar] = threadInfo.cBaseUrl, - var cRoomToken: [CChar] = threadInfo.cRoomToken, - var cPubkey: [UInt8] = threadInfo.cPubkey + var cBaseUrl: [CChar] = threadInfo.openGroupUrlInfo?.server.cArray, + var cRoomToken: [CChar] = threadInfo.openGroupUrlInfo?.roomToken.cArray, + var cPubkey: [UInt8] = threadInfo.openGroupUrlInfo?.publicKey.bytes else { SNLog("Unable to create community conversation when updating last read timestamp due to missing URL info") return } - var openGroup: convo_info_volatile_open = convo_info_volatile_open() + var community: convo_info_volatile_community = convo_info_volatile_community() - guard convo_info_volatile_get_or_construct_open(conf, &openGroup, &cBaseUrl, &cRoomToken, &cPubkey) else { + guard convo_info_volatile_get_or_construct_community(conf, &community, &cBaseUrl, &cRoomToken, &cPubkey) else { SNLog("Unable to create legacy group conversation when updating last read timestamp") return } @@ -248,15 +250,15 @@ internal extension SessionUtil { threadInfo.changes.forEach { change in switch change { case .lastReadTimestampMs(let lastReadMs): - openGroup.last_read = lastReadMs + community.last_read = lastReadMs case .markedAsUnread(let unread): - openGroup.unread = unread + community.unread = unread } } - convo_info_volatile_set_open(conf, &openGroup) + convo_info_volatile_set_community(conf, &community) - case .closedGroup: return // TODO: Need to add when the type is added to the lib + case .group: return // TODO: Need to add when the type is added to the lib } } @@ -284,7 +286,7 @@ internal extension SessionUtil { VolatileThreadInfo( threadId: thread.id, variant: thread.variant, - openGroupUrlInfo: (thread.variant != .openGroup ? nil : + openGroupUrlInfo: (thread.variant != .community ? nil : try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: thread.id) ), changes: [.markedAsUnread(thread.markedAsUnread ?? false)] @@ -340,7 +342,7 @@ internal extension SessionUtil { let change: VolatileThreadInfo = VolatileThreadInfo( threadId: threadId, variant: threadVariant, - openGroupUrlInfo: (threadVariant != .openGroup ? nil : + openGroupUrlInfo: (threadVariant != .community ? nil : try VolatileThreadInfo.OpenGroupUrlInfo.fetchOne(db, id: threadId) ), changes: [.lastReadTimestampMs(lastReadTimestampMs)] @@ -394,47 +396,36 @@ internal extension SessionUtil { return atomicConf.mutate { conf in switch threadVariant { case .contact: - var cThreadId: [CChar] = threadId - .bytes - .map { CChar(bitPattern: $0) } + var cThreadId: [CChar] = threadId.cArray var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() guard convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) else { return false } return (oneToOne.last_read > timestampMs) - case .legacyClosedGroup: - var cThreadId: [CChar] = threadId - .bytes - .map { CChar(bitPattern: $0) } - var legacyClosedGroup: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + case .legacyGroup: + var cThreadId: [CChar] = threadId.cArray + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() - guard convo_info_volatile_get_legacy_closed(conf, &legacyClosedGroup, &cThreadId) else { + guard convo_info_volatile_get_legacy_group(conf, &legacyGroup, &cThreadId) else { return false } - return (legacyClosedGroup.last_read > timestampMs) + return (legacyGroup.last_read > timestampMs) - case .openGroup: + case .community: guard let openGroup: OpenGroup = openGroup else { return false } - var cBaseUrl: [CChar] = openGroup.server - .bytes - .map { CChar(bitPattern: $0) } - var cRoomToken: [CChar] = openGroup.roomToken - .bytes - .map { CChar(bitPattern: $0) } - var cPubKey: [CChar] = openGroup.publicKey - .bytes - .map { CChar(bitPattern: $0) } - var convoOpenGroup: convo_info_volatile_open = convo_info_volatile_open() + var cBaseUrl: [CChar] = openGroup.server.cArray + var cRoomToken: [CChar] = openGroup.roomToken.cArray + var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() - guard convo_info_volatile_get_open(conf, &convoOpenGroup, &cBaseUrl, &cRoomToken, &cPubKey) else { + guard convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) else { return false } - return (convoOpenGroup.last_read > timestampMs) + return (convoCommunity.last_read > timestampMs) - case .closedGroup: return false // TODO: Need to add when the type is added to the lib + case .group: return false // TODO: Need to add when the type is added to the lib } } } @@ -466,28 +457,9 @@ public extension SessionUtil { let threadId: String let variant: SessionThread.Variant - private let openGroupUrlInfo: OpenGroupUrlInfo? + fileprivate let openGroupUrlInfo: OpenGroupUrlInfo? let changes: [Change] - var cThreadId: [CChar] { - threadId.bytes.map { CChar(bitPattern: $0) } - } - var cBaseUrl: [CChar]? { - (openGroupUrlInfo?.server).map { - $0.bytes.map { CChar(bitPattern: $0) } - } - } - var cRoomToken: [CChar]? { - (openGroupUrlInfo?.roomToken).map { - $0.bytes.map { CChar(bitPattern: $0) } - } - } - var cPubkey: [UInt8]? { - (openGroupUrlInfo?.publicKey).map { - Data(hex: $0).bytes - } - } - fileprivate init( threadId: String, variant: SessionThread.Variant, diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift index caf197057..f6c1cb8d6 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -31,23 +31,18 @@ internal extension SessionUtil { let profileName: String = String(cString: profileNamePtr) let profilePic: user_profile_pic = user_profile_get_pic(conf) - var profilePictureUrl: String? = nil - var profilePictureKey: Data? = nil - - // Make sure the url and key exist before reading the memory - if - profilePic.keylen > 0, - let profilePictureUrlPtr: UnsafePointer = profilePic.url, - let profilePictureKeyPtr: UnsafePointer = profilePic.key - { - profilePictureUrl = String(cString: profilePictureUrlPtr) - profilePictureKey = Data(bytes: profilePictureKeyPtr, count: profilePic.keylen) - } + let profilePictureUrl: String? = String(libSessionVal: profilePic.url, nullIfEmpty: true) + // Make sure the url and key exists before reading the memory return ( profileName: profileName, profilePictureUrl: profilePictureUrl, - profilePictureKey: profilePictureKey + profilePictureKey: (profilePictureUrl == nil ? nil : + Data( + libSessionVal: profilePic.url, + count: ProfileManager.avatarAES256KeyByteLength + ) + ) ) } @@ -106,35 +101,14 @@ internal extension SessionUtil { // blocking access in it's `mutate` closure return atomicConf.mutate { conf in // Update the name - var updatedName: [CChar] = profile.name - .bytes - .map { CChar(bitPattern: $0) } + var updatedName: [CChar] = profile.name.cArray user_profile_set_name(conf, &updatedName) // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) - let profilePic: user_profile_pic? = { - guard - let profilePictureUrl: String = profile.profilePictureUrl, - let profileEncryptionKey: Data = profile.profileEncryptionKey - else { return nil } - - let updatedUrl: [CChar] = profilePictureUrl - .bytes - .map { CChar(bitPattern: $0) } - let updatedKey: [UInt8] = profileEncryptionKey - .bytes - - return updatedUrl.withUnsafeBufferPointer { urlPtr in - updatedKey.withUnsafeBufferPointer { keyPtr in - user_profile_pic( - url: urlPtr.baseAddress, - key: keyPtr.baseAddress, - keylen: updatedKey.count - ) - } - } - }() - user_profile_set_pic(conf, (profilePic ?? user_profile_pic())) + var profilePic: user_profile_pic = user_profile_pic() + profilePic.url = profile.profilePictureUrl.toLibSession() + profilePic.key = profile.profileEncryptionKey.toLibSession() + user_profile_set_pic(conf, profilePic) return ConfResult( needsPush: config_needs_push(conf), diff --git a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift index 7e9b38608..b16038aa2 100644 --- a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift @@ -55,6 +55,8 @@ public enum SessionUtil { } } + public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) } + // MARK: - Loading public static func loadState( @@ -133,6 +135,9 @@ public enum SessionUtil { case .convoInfoVolatile: return convo_info_volatile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .userGroups: + return user_groups_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) } }() @@ -208,11 +213,10 @@ public enum SessionUtil { // MARK: - Pushes - public static func pendingChanges( - _ db: Database, - userPublicKey: String, - ed25519SecretKey: [UInt8] - ) throws -> [OutgoingConfResult] { + public static func pendingChanges(_ db: Database) throws -> [OutgoingConfResult] { + guard Identity.userExists(db) else { throw SessionUtilError.userDoesNotExist } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) let existingDumpInfo: Set = try ConfigDump .select(.variant, .publicKey, .combinedMessageHashes) .asRequest(of: DumpInfo.self) @@ -287,6 +291,20 @@ public enum SessionUtil { return config_needs_dump(atomicConf.wrappedValue) } + public static func configHashes(for publicKey: String) -> [String] { + return Storage.shared + .read { db in + try ConfigDump + .filter(ConfigDump.Columns.publicKey == publicKey) + .select(.combinedMessageHashes) + .asRequest(of: String.self) + .fetchAll(db) + } + .defaulting(to: []) + .compactMap { ConfigDump.messageHashes(from: $0) } + .flatMap { $0 } + } + // MARK: - Receiving public static func handleConfigMessages( @@ -373,7 +391,7 @@ public enum SessionUtil { mergeResult: mergeResult.result ) - case .groups: + case .userGroups: return try SessionUtil.handleGroupsUpdate( db, in: atomicConf, diff --git a/SessionMessagingKit/LibSessionUtil/SessionUtilError.swift b/SessionMessagingKit/LibSessionUtil/SessionUtilError.swift index 2425f7398..ff887336a 100644 --- a/SessionMessagingKit/LibSessionUtil/SessionUtilError.swift +++ b/SessionMessagingKit/LibSessionUtil/SessionUtilError.swift @@ -5,4 +5,5 @@ import Foundation public enum SessionUtilError: Error { case unableToCreateConfigObject case nilConfigObject + case userDoesNotExist } diff --git a/SessionMessagingKit/LibSessionUtil/Utilities/TypeConversion+Utilities.swift b/SessionMessagingKit/LibSessionUtil/Utilities/TypeConversion+Utilities.swift new file mode 100644 index 000000000..f418ecae2 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/Utilities/TypeConversion+Utilities.swift @@ -0,0 +1,104 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - String + +public extension String { + var cArray: [CChar] { [UInt8](self.utf8).map { CChar(bitPattern: $0) } } + + /// Initialize with an optional pointer and a specific length + init?(pointer: UnsafeRawPointer?, length: Int, encoding: String.Encoding = .utf8) { + guard + let pointer: UnsafeRawPointer = pointer, + let result: String = String(data: Data(bytes: pointer, count: length), encoding: encoding) + else { return nil } + + self = result + } + + init?( + libSessionVal: T, + nullTerminated: Bool = true, + nullIfEmpty: Bool = false + ) { + let result: String = { + guard !nullTerminated else { + return String(cString: withUnsafeBytes(of: libSessionVal) { [UInt8]($0) }) + } + + return String( + data: Data(libSessionVal: libSessionVal, count: MemoryLayout.size), + encoding: .utf8 + ) + .defaulting(to: "") + }() + + guard !nullIfEmpty || !result.isEmpty else { return nil } + + self = result + } + + func toLibSession() -> T { + let targetSize: Int = MemoryLayout.stride + let result: UnsafeMutableRawPointer = UnsafeMutableRawPointer.allocate( + byteCount: targetSize, + alignment: MemoryLayout.alignment + ) + self.utf8CString.withUnsafeBytes { result.copyMemory(from: $0.baseAddress!, byteCount: $0.count) } + + return result.withMemoryRebound(to: T.self, capacity: targetSize) { $0.pointee } + } +} + +public extension Optional { + func toLibSession() -> T { + switch self { + case .some(let value): return value.toLibSession() + case .none: return "".toLibSession() + } + } +} + +// MARK: - Data + +public extension Data { + var cArray: [UInt8] { [UInt8](self) } + + init(libSessionVal: T, count: Int) { + self = Data( + bytes: Swift.withUnsafeBytes(of: libSessionVal) { [UInt8]($0) }, + count: count + ) + } + + func toLibSession() -> T { + let targetSize: Int = MemoryLayout.stride + let result: UnsafeMutableRawPointer = UnsafeMutableRawPointer.allocate( + byteCount: targetSize, + alignment: MemoryLayout.alignment + ) + self.withUnsafeBytes { result.copyMemory(from: $0.baseAddress!, byteCount: $0.count) } + + return result.withMemoryRebound(to: T.self, capacity: targetSize) { $0.pointee } + } +} + +public extension Optional { + func toLibSession() -> T { + switch self { + case .some(let value): return value.toLibSession() + case .none: return Data().toLibSession() + } + } +} + +// MARK: - Array + +public extension Array where Element == CChar { + func nullTerminated() -> [Element] { + guard self.last != CChar(0) else { return self } + + return self.appending(CChar(0)) + } +} diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist index 97310ed4d..c9a2d9b3e 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/Info.plist @@ -4,6 +4,18 @@ AvailableLibraries + + LibraryIdentifier + ios-arm64 + LibraryPath + libsession-util.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + LibraryIdentifier ios-arm64_x86_64-simulator @@ -19,18 +31,6 @@ SupportedPlatformVariant simulator - - LibraryIdentifier - ios-arm64 - LibraryPath - libsession-util.a - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - CFBundlePackageType XFWK diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a index ba172f550..3f7bee486 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a index 65cfc8dcf..de85c6780 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap index 37ffdf02d..8d442880b 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap @@ -1,8 +1,11 @@ module SessionUtil { module capi { + header "session/version.h" header "session/export.h" header "session/config.h" header "session/config/error.h" + header "session/config/expiring.h" + header "session/config/user_groups.h" header "session/config/convo_info_volatile.h" header "session/config/user_profile.h" header "session/config/util.h" diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config.hpp index c59809861..cd6e851aa 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config.hpp @@ -225,7 +225,9 @@ class ConfigMessage { // Constructor tag struct increment_seqno_t {}; +struct retain_seqno_t {}; inline constexpr increment_seqno_t increment_seqno{}; +inline constexpr retain_seqno_t retain_seqno{}; class MutableConfigMessage : public ConfigMessage { protected: @@ -292,7 +294,14 @@ class MutableConfigMessage : public ConfigMessage { /// Constructor that does the same thing as the `m.increment()` factory method. The second /// value should be the literal `increment_seqno` value (to select this constructor). - explicit MutableConfigMessage(const ConfigMessage& m, increment_seqno_t); + explicit MutableConfigMessage(const ConfigMessage& m, const increment_seqno_t&); + + /// Constructor that moves a immutable message into a mutable one, retaining the current seqno. + /// This is typically used in situations where the ConfigMessage has had some implicit seqno + /// increment already (e.g. from merging) and we want it to become mutable without incrementing + /// the seqno again. The second value should be the literal `retain_seqno` value (to select + /// this constructor). + explicit MutableConfigMessage(ConfigMessage&& m, const retain_seqno_t&); using ConfigMessage::data; /// Returns a mutable reference to the underlying config data. diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp index 86cdeab24..deb7b16f0 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp @@ -90,6 +90,7 @@ class ConfigBase { // already dirty (i.e. Clean or Waiting) then calling this increments the seqno counter. MutableConfigMessage& dirty(); + public: // class for proxying subfield access; this class should never be stored but only used // ephemerally (most of its methods are rvalue-qualified). This lets constructs such as // foo["abc"]["def"]["ghi"] = 12; @@ -271,7 +272,7 @@ class ConfigBase { std::string string_or(std::string fallback) const { if (auto* s = string()) return *s; - return std::move(fallback); + return fallback; } /// Returns a const pointer to the integer if one exists at the given location, nullptr @@ -297,7 +298,7 @@ class ConfigBase { /// Replaces the current value with the given string. This also auto-vivifies any /// intermediate dicts needed to reach the given key, including replacing non-dict values if /// they currently exist along the path. - void operator=(std::string value) { assign_if_changed(std::move(value)); } + void operator=(std::string&& value) { assign_if_changed(std::move(value)); } /// Same as above, but takes a string_view for convenience (this makes a copy). void operator=(std::string_view value) { *this = std::string{value}; } /// Same as above, but takes a ustring_view @@ -391,6 +392,7 @@ class ConfigBase { } }; + protected: // Called when dumping to obtain any extra data that a subclass needs to store to reconstitute // the object. The base implementation does nothing. The counterpart to this, // `load_extra_data()`, is called when loading from a dump that has extra data; a subclass @@ -429,6 +431,11 @@ class ConfigBase { /// to use. This is rarely needed externally; it is public merely for testing purposes. virtual const char* encryption_domain() const = 0; + /// The zstd compression level to use for this type. Subclasses can override this if they have + /// some particular special compression level, or to disable compression entirely (by returning + /// std::nullopt). The default is zstd level 1. + virtual std::optional compression_level() const { return 1; } + // How many config lags should be used for this object; default to 5. Implementing subclasses // can override to return a different constant if desired. More lags require more "diff" // storage in the config messages, but also allow for a higher tolerance of simultaneous message @@ -463,13 +470,16 @@ class ConfigBase { // the server. This will be true whenever `is_clean()` is false: that is, if we are currently // "dirty" (i.e. have changes that haven't been pushed) or are still awaiting confirmation of // storage of the most recent serialized push data. - bool needs_push() const; + virtual bool needs_push() const; // Returns the data messages to push to the server along with the seqno value of the data. If // the config is currently dirty (i.e. has previously unsent modifications) then this marks it // as awaiting-confirmation instead of dirty so that any future change immediately increments // the seqno. - std::pair push(); + // + // Subclasses that need to perform pre-push tasks (such as pruning stale data) can override this + // to prune and then call the base method to perform the actual push generation. + virtual std::pair push(); // Should be called after the push is confirmed stored on the storage server swarm to let the // object know the data is stored. (Once this is called `needs_push` will start returning false diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/community.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/community.hpp new file mode 100644 index 000000000..daffb7886 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/community.hpp @@ -0,0 +1,239 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace session::config { + +/// Base class for types representing a community; this base type handles the url/room/pubkey that +/// such a type need. Generally a class inherits from this to extend with the local +/// community-related values. +struct community { + + // 267 = len('https://') + 253 (max valid DNS name length) + len(':XXXXX') + static constexpr size_t URL_MAX_LENGTH = 267; + static constexpr size_t ROOM_MAX_LENGTH = 64; + + community() = default; + + // Constructs an empty community struct from url, room, and pubkey. `base_url` will be + // normalized if not already. pubkey is 32 bytes. + community(std::string_view base_url, std::string_view room, ustring_view pubkey); + + // Same as above, but takes pubkey as an encoded (hex or base32z or base64) string. + community(std::string_view base_url, std::string_view room, std::string_view pubkey_encoded); + + // Takes a combined room URL (e.g. https://whatever.com/r/Room?public_key=01234....), either + // new style (with /r/) or old style (without /r/). Note that the URL gets canonicalized so + // the resulting `base_url()` and `room()` values may not be exactly equal to what is given. + // + // See also `parse_full_url` which does the same thing but returns it in pieces rather than + // constructing a new `community` object. + explicit community(std::string_view full_url); + + // Replaces the baseurl/room/pubkey of this object from a URL. This parses the URL, then stores + // the values as if passed to set_base_url/set_room/set_pubkey. + // + // The base URL will be normalized; the room name will be case-preserving (but see `set_room` + // for info on limitations on "case-preserving", particularly for volatile configs); and the + // embedded pubkey must be encoded in one of hex, base32z, or base64. + void set_full_url(std::string_view full_url); + + // Replaces the base_url of this object. Note that changing the URL and then giving it to `set` + // will end up inserting a *new* record but not removing the *old* one (you need to erase first + // to do that). + void set_base_url(std::string_view new_url); + + // Changes the room token. This stores (or updates) the name as given as the localized room, + // and separately stores the normalized (lower-case) token. Note that the localized name does + // not persist across a push or dump in some config contexts (such as volatile room info). If + // the new room given here changes more than just case (i.e. if the normalized room token + // changes) then a call to `set` will end up inserting a *new* record but not removing the *old* + // one (you need to erase first to do that). + void set_room(std::string_view room); + + // Updates the pubkey of this community (typically this is not called directly but rather + // via `set_server` or during construction). Throws std::invalid_argument if the given + // pubkey does not look like a valid pubkey. The std::string_view version takes the pubkey + // as any of hex/base64/base32z. + // + // NOTE: the pubkey of all communities with the same URLs are stored in common, so changing + // one community pubkey (and storing) will affect all communities using the same community + // base URL. + void set_pubkey(ustring_view pubkey); + void set_pubkey(std::string_view pubkey); + + // Accesses the base url (i.e. not including room or pubkey). Always lower-case/normalized. + const std::string& base_url() const { return base_url_; } + + // Accesses the room token; this is case-preserving, where possible. In some contexts, however, + // such as volatile info, the case is not preserved and this will always return the normalized + // (lower-case) form rather than the preferred form. + const std::string& room() const { return localized_room_ ? *localized_room_ : room_; } + + // Accesses the normalized room token, i.e. always lower-case. + const std::string& room_norm() const { return room_; } + + const ustring& pubkey() const { return pubkey_; } // Accesses the server pubkey (32 bytes). + std::string pubkey_hex() const; // Accesses the server pubkey as hex (64 hex digits). + std::string pubkey_b32z() const; // Accesses the server pubkey as base32z (52 alphanumeric + // digits) + std::string pubkey_b64() const; // Accesses the server pubkey as unpadded base64 (43 from + // alphanumeric, '+', and '/'). + + // Takes a base URL as input and returns it in canonical form. This involves doing things + // like lower casing it and removing redundant ports (e.g. :80 when using http://). Throws + // std::invalid_argument if given an invalid base URL. + static std::string canonical_url(std::string_view url); + + // Takes a room token and returns it in canonical form (i.e. lower-cased). Throws + // std::invalid_argument if given an invalid room token (e.g. too long, or containing token + // other than a-z, 0-9, -, _). + static std::string canonical_room(std::string_view room); + + // Same as above, but modifies the argument in-place instead of returning a modified + // copy. + static void canonicalize_url(std::string& url); + static void canonicalize_room(std::string& room); + + // Takes a full room URL, splits it up into canonical url (see above), room, and server + // pubkey. We take both the deprecated form (e.g. + // https://example.com/SomeRoom?public_key=...) and new form + // (https://example.com/r/SomeRoom?public_key=...). The public_key is typically specified + // in hex (64 digits), but we also accept base64 (43 chars or 44 with padding) and base32z + // (52 chars) encodings (for slightly shorter URLs). + // + // The returned URL is normalized (lower-cased, and cleaned up). + // + // The returned room name is *not* normalized, that is, it preserve case. + // + // Throw std::invalid_argument if anything in the URL is unparseable or invalid. + static std::tuple parse_full_url(std::string_view full_url); + + protected: + // The canonical base url and room (i.e. lower-cased, URL cleaned up): + std::string base_url_, room_; + // The localized token of this room, that is, with case preserved (so `room_` could be + // `someroom` and this could `SomeRoom`). Omitted if not available. + std::optional localized_room_; + // server pubkey + ustring pubkey_; + + // Construction without a pubkey for when pubkey isn't known yet but will be set shortly + // after constructing (or when isn't needed, such as when deleting). + community(std::string_view base_url, std::string_view room); +}; + +struct comm_iterator_helper { + + comm_iterator_helper(dict::const_iterator it_server, dict::const_iterator end_server) : + it_server{std::move(it_server)}, end_server{std::move(end_server)} {} + + std::optional it_server, end_server, it_room, end_room; + + bool operator==(const comm_iterator_helper& other) const { + return it_server == other.it_server && it_room == other.it_room; + } + + void next_server() { + ++*it_server; + it_room.reset(); + end_room.reset(); + } + + bool done() const { return !it_server || *it_server == *end_server; } + + template + bool load(std::shared_ptr& val) { + while (it_server) { + if (*it_server == *end_server) { + it_server.reset(); + end_server.reset(); + return false; + } + + auto& [base_url, server_info] = **it_server; + auto* server_info_dict = std::get_if(&server_info); + if (!server_info_dict) { + next_server(); + continue; + } + + const std::string* pubkey_raw = nullptr; + if (auto pubkey_it = server_info_dict->find("#"); pubkey_it != server_info_dict->end()) + if (auto* pk_sc = std::get_if(&pubkey_it->second)) + pubkey_raw = std::get_if(pk_sc); + + if (!pubkey_raw) { + next_server(); + continue; + } + + ustring_view pubkey{ + reinterpret_cast(pubkey_raw->data()), pubkey_raw->size()}; + + if (!it_room) { + if (auto rit = server_info_dict->find("R"); + rit != server_info_dict->end() && std::holds_alternative(rit->second)) { + auto& rooms_dict = std::get(rit->second); + it_room = rooms_dict.begin(); + end_room = rooms_dict.end(); + } else { + next_server(); + continue; + } + } + + while (it_room) { + if (*it_room == *end_room) { + it_room.reset(); + end_room.reset(); + break; + } + + auto& [room, data] = **it_room; + auto* data_dict = std::get_if(&data); + if (!data_dict) { + ++*it_room; + continue; + } + + val = std::make_shared(Comm{}); + auto& og = std::get(*val); + try { + og.set_base_url(base_url); + og.set_room(room); // Will be replaced with "n" in the `.load` below + og.set_pubkey(pubkey); + og.load(*data_dict); + } catch (const std::exception& e) { + ++*it_room; + continue; + } + return true; + } + + ++*it_server; + } + + return false; + } + + bool advance() { + if (it_room) { + ++*it_room; + return true; + } + if (it_server) { + ++*it_server; + return true; + } + return false; + } +}; + +} // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h index 0046bfd4e..76fd8acf5 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h @@ -5,20 +5,27 @@ extern "C" { #endif #include "base.h" +#include "expiring.h" #include "profile_pic.h" #include "util.h" typedef struct contacts_contact { char session_id[67]; // in hex; 66 hex chars + null terminator. - // These can be NULL. When setting, either NULL or empty string will clear the setting. - const char* name; - const char* nickname; + // These two will be 0-length strings when unset: + char name[101]; + char nickname[101]; user_profile_pic profile_pic; bool approved; bool approved_me; bool blocked; + bool hidden; + + int priority; + + CONVO_EXPIRATION_MODE exp_mode; + int exp_minutes; } contacts_contact; diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp index a4d33234a..01bc8ce55 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp @@ -1,16 +1,20 @@ #pragma once +#include #include #include #include #include #include "base.hpp" +#include "expiring.hpp" #include "namespaces.hpp" #include "profile_pic.hpp" extern "C" struct contacts_contact; +using namespace std::literals; + namespace session::config { /// keys used in this config, either currently or in the past (so that we don't reuse): @@ -18,30 +22,41 @@ namespace session::config { /// c - dict of contacts; within this dict each key is the session pubkey (binary, 33 bytes) and /// value is a dict containing keys: /// -/// ! - dummy value that is always set to an empty string. This ensures that we always have at -/// least one key set, which is required to keep the dict value alive (empty dicts get -/// pruned when serialied). -/// n - contact name (string) +/// n - contact name (string). This is always serialized, even if empty (but empty indicates +/// no name) so that we always have at least one key set (required to keep the dict value +/// alive as empty dicts get pruned). /// N - contact nickname (string) /// p - profile url (string) /// q - profile decryption key (binary) /// a - 1 if approved, omitted otherwise (int) /// A - 1 if remote has approved me, omitted otherwise (int) /// b - 1 if contact is blocked, omitted otherwise +/// h - 1 if the conversation with this contact is hidden, omitted if visible. +/// + - the conversation priority, for pinned messages. Omitted means not pinned; otherwise an +/// integer value >0, where a higher priority means the conversation is meant to appear +/// earlier in the pinned conversation list. +/// e - Disappearing messages expiration type. Omitted if disappearing messages are not enabled +/// for the conversation with this contact; 1 for delete-after-send, and 2 for +/// delete-after-read. +/// E - Disappearing message timer, in minutes. Omitted when `e` is omitted. -/// Struct containing contact info. Note that data must be copied/used immediately as the data will -/// not remain valid beyond other calls into the library. When settings things in this externally -/// (e.g. to pass into `set()`), take note that the `name` and `nickname` are string_views: that is, -/// they must reference existing string data that remains valid for the duration of the contact_info -/// instance. +/// Struct containing contact info. struct contact_info { + static constexpr size_t MAX_NAME_LENGTH = 100; + std::string session_id; // in hex - std::optional name; - std::optional nickname; - std::optional profile_picture; + std::string name; + std::string nickname; + profile_pic profile_picture; bool approved = false; bool approved_me = false; bool blocked = false; + bool hidden = false; // True if the conversation with this contact is not visible in the convo + // list (typically because it has been deleted). + int priority = 0; // If >0 then this message is pinned; higher values mean higher priority + // (i.e. pinned earlier in the pinned list). + expiration_mode exp_mode = expiration_mode::none; // The expiry time; none if not expiring. + std::chrono::minutes exp_timer{0}; // The expiration timer (in minutes) explicit contact_info(std::string sid); @@ -49,20 +64,13 @@ struct contact_info { contact_info(const struct contacts_contact& c); // From c struct void into(contacts_contact& c) const; // Into c struct - // Sets a name, storing the name internally in the object. This is intended for use where the - // source string is a temporary may not outlive the `contact_info` object: the name is first - // copied into an internal std::string, and then the name string_view references that. + // Sets a name or nickname; this is exactly the same as assigning to .name/.nickname directly, + // except that we throw an exception if the given name is longer than MAX_NAME_LENGTH. void set_name(std::string name); - - // Same as above, but for nickname. void set_nickname(std::string nickname); private: friend class Contacts; - - std::string name_; - std::string nickname_; - void load(const dict& info_dict); }; @@ -111,13 +119,20 @@ class Contacts : public ConfigBase { /// contacts.set(c); void set(const contact_info& contact); - /// Alternative to `set()` for setting individual fields. + /// Alternative to `set()` for setting a single field. (If setting multiple fields at once you + /// should use `set()` instead). void set_name(std::string_view session_id, std::string name); void set_nickname(std::string_view session_id, std::string nickname); void set_profile_pic(std::string_view session_id, profile_pic pic); void set_approved(std::string_view session_id, bool approved); void set_approved_me(std::string_view session_id, bool approved_me); void set_blocked(std::string_view session_id, bool blocked); + void set_hidden(std::string_view session_id, bool hidden); + void set_priority(std::string_view session_id, int priority); + void set_expiry( + std::string_view session_id, + expiration_mode exp_mode, + std::chrono::minutes expiration_timer = 0min); /// Removes a contact, if present. Returns true if it was found and removed, false otherwise. /// Note that this removes all fields related to a contact, even fields we do not know about. diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h index 9ec098ece..94e103ea5 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.h @@ -14,7 +14,7 @@ typedef struct convo_info_volatile_1to1 { bool unread; // true if the conversation is explicitly marked unread } convo_info_volatile_1to1; -typedef struct convo_info_volatile_open { +typedef struct convo_info_volatile_community { char base_url[268]; // null-terminated (max length 267), normalized (i.e. always lower-case, // only has port if non-default, has trailing / removed) char room[65]; // null-terminated (max length 64), normalized (always lower-case) @@ -22,15 +22,15 @@ typedef struct convo_info_volatile_open { int64_t last_read; // ms since unix epoch bool unread; // true if marked unread -} convo_info_volatile_open; +} convo_info_volatile_community; -typedef struct convo_info_volatile_legacy_closed { +typedef struct convo_info_volatile_legacy_group { char group_id[67]; // in hex; 66 hex chars + null terminator. Looks just like a Session ID, // though isn't really one. int64_t last_read; // ms since unix epoch bool unread; // true if marked unread -} convo_info_volatile_legacy_closed; +} convo_info_volatile_legacy_group; /// Constructs a conversations config object and sets a pointer to it in `conf`. /// @@ -78,76 +78,75 @@ bool convo_info_volatile_get_or_construct_1to1( const config_object* conf, convo_info_volatile_1to1* convo, const char* session_id) __attribute__((warn_unused_result)); -/// open-group versions of the 1-to-1 functions: +/// community versions of the 1-to-1 functions: /// -/// Gets an open group convo info. `base_url` and `room` are null-terminated c strings; pubkey is +/// Gets a community convo info. `base_url` and `room` are null-terminated c strings; pubkey is /// 32 bytes. base_url and room will always be lower-cased (if not already). -bool convo_info_volatile_get_open( +bool convo_info_volatile_get_community( const config_object* conf, - convo_info_volatile_open* og, + convo_info_volatile_community* comm, const char* base_url, - const char* room, - unsigned const char* pubkey) __attribute__((warn_unused_result)); -bool convo_info_volatile_get_or_construct_open( + const char* room) __attribute__((warn_unused_result)); +bool convo_info_volatile_get_or_construct_community( const config_object* conf, - convo_info_volatile_open* convo, + convo_info_volatile_community* convo, const char* base_url, const char* room, unsigned const char* pubkey) __attribute__((warn_unused_result)); -/// Fills `convo` with the conversation info given a legacy closed group ID (specified as a -/// null-terminated hex string), if the conversation exists, and returns true. If the conversation -/// does not exist then `convo` is left unchanged and false is returned. -bool convo_info_volatile_get_legacy_closed( - const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id) +/// Fills `convo` with the conversation info given a legacy group ID (specified as a null-terminated +/// hex string), if the conversation exists, and returns true. If the conversation does not exist +/// then `convo` is left unchanged and false is returned. +bool convo_info_volatile_get_legacy_group( + const config_object* conf, convo_info_volatile_legacy_group* convo, const char* id) __attribute__((warn_unused_result)); /// Same as the above except that when the conversation does not exist, this sets all the convo /// fields to defaults and loads it with the given id. /// -/// Returns true as long as it is given a valid legacy closed group id (i.e. same format as a -/// session id). A false return is considered an error, and means the id was not a valid session -/// id. +/// Returns true as long as it is given a valid legacy group id (i.e. same format as a session id). +/// A false return is considered an error, and means the id was not a valid session id. /// /// This is the method that should usually be used to create or update a conversation, followed by /// setting fields in the convo, and then giving it to convo_info_volatile_set(). -bool convo_info_volatile_get_or_construct_legacy_closed( - const config_object* conf, convo_info_volatile_legacy_closed* convo, const char* id) +bool convo_info_volatile_get_or_construct_legacy_group( + const config_object* conf, convo_info_volatile_legacy_group* convo, const char* id) __attribute__((warn_unused_result)); /// Adds or updates a conversation from the given convo info void convo_info_volatile_set_1to1(config_object* conf, const convo_info_volatile_1to1* convo); -void convo_info_volatile_set_open(config_object* conf, const convo_info_volatile_open* convo); -void convo_info_volatile_set_legacy_closed( - config_object* conf, const convo_info_volatile_legacy_closed* convo); +void convo_info_volatile_set_community( + config_object* conf, const convo_info_volatile_community* convo); +void convo_info_volatile_set_legacy_group( + config_object* conf, const convo_info_volatile_legacy_group* convo); /// Erases a conversation from the conversation list. Returns true if the conversation was found /// and removed, false if the conversation was not present. You must not call this during /// iteration; see details below. bool convo_info_volatile_erase_1to1(config_object* conf, const char* session_id); -bool convo_info_volatile_erase_open( - config_object* conf, const char* base_url, const char* room, unsigned const char* pubkey); -bool convo_info_volatile_erase_legacy_closed(config_object* conf, const char* group_id); +bool convo_info_volatile_erase_community( + config_object* conf, const char* base_url, const char* room); +bool convo_info_volatile_erase_legacy_group(config_object* conf, const char* group_id); /// Returns the number of conversations. size_t convo_info_volatile_size(const config_object* conf); /// Returns the number of conversations of the specific type. size_t convo_info_volatile_size_1to1(const config_object* conf); -size_t convo_info_volatile_size_open(const config_object* conf); -size_t convo_info_volatile_size_legacy_closed(const config_object* conf); +size_t convo_info_volatile_size_communities(const config_object* conf); +size_t convo_info_volatile_size_legacy_groups(const config_object* conf); /// Functions for iterating through the entire conversation list. Intended use is: /// /// convo_info_volatile_1to1 c1; -/// convo_info_volatile_open c2; -/// convo_info_volatile_legacy_closed c3; +/// convo_info_volatile_community c2; +/// convo_info_volatile_legacy_group c3; /// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos); /// for (; !convo_info_volatile_iterator_done(it); convo_info_volatile_iterator_advance(it)) { /// if (convo_info_volatile_it_is_1to1(it, &c1)) { /// // use c1.whatever -/// } else if (convo_info_volatile_it_is_open(it, &c2)) { +/// } else if (convo_info_volatile_it_is_community(it, &c2)) { /// // use c2.whatever -/// } else if (convo_info_volatile_it_is_legacy_closed(it, &c3)) { +/// } else if (convo_info_volatile_it_is_legacy_group(it, &c3)) { /// // use c3.whatever /// } /// } @@ -169,6 +168,8 @@ size_t convo_info_volatile_size_legacy_closed(const config_object* conf); /// convo_info_volatile_iterator_erase(it); /// else /// convo_info_volatile_iterator_advance(it); +/// } else { +/// convo_info_volatile_iterator_advance(it); /// } /// } /// convo_info_volatile_iterator_free(it); @@ -185,8 +186,9 @@ convo_info_volatile_iterator* convo_info_volatile_iterator_new(const config_obje // of the `it_is_whatever` function: it will always be true for the particular type being iterated // over). convo_info_volatile_iterator* convo_info_volatile_iterator_new_1to1(const config_object* conf); -convo_info_volatile_iterator* convo_info_volatile_iterator_new_open(const config_object* conf); -convo_info_volatile_iterator* convo_info_volatile_iterator_new_legacy_closed( +convo_info_volatile_iterator* convo_info_volatile_iterator_new_communities( + const config_object* conf); +convo_info_volatile_iterator* convo_info_volatile_iterator_new_legacy_groups( const config_object* conf); // Frees an iterator once no longer needed. @@ -202,14 +204,15 @@ void convo_info_volatile_iterator_advance(convo_info_volatile_iterator* it); // returns true. Otherwise it returns false. bool convo_info_volatile_it_is_1to1(convo_info_volatile_iterator* it, convo_info_volatile_1to1* c); -// If the current iterator record is an open group conversation this sets the details into `c` and +// If the current iterator record is a community conversation this sets the details into `c` and // returns true. Otherwise it returns false. -bool convo_info_volatile_it_is_open(convo_info_volatile_iterator* it, convo_info_volatile_open* c); +bool convo_info_volatile_it_is_community( + convo_info_volatile_iterator* it, convo_info_volatile_community* c); -// If the current iterator record is a legacy closed group conversation this sets the details into -// `c` and returns true. Otherwise it returns false. -bool convo_info_volatile_it_is_legacy_closed( - convo_info_volatile_iterator* it, convo_info_volatile_legacy_closed* c); +// If the current iterator record is a legacy group conversation this sets the details into `c` and +// returns true. Otherwise it returns false. +bool convo_info_volatile_it_is_legacy_group( + convo_info_volatile_iterator* it, convo_info_volatile_legacy_group* c); // Erases the current convo while advancing the iterator to the next convo in the iteration. void convo_info_volatile_iterator_erase(config_object* conf, convo_info_volatile_iterator* it); diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp index f6de101e3..16e256fa4 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/convo_info_volatile.hpp @@ -7,11 +7,14 @@ #include #include "base.hpp" +#include "community.hpp" + +using namespace std::literals; extern "C" { struct convo_info_volatile_1to1; -struct convo_info_volatile_open; -struct convo_info_volatile_legacy_closed; +struct convo_info_volatile_community; +struct convo_info_volatile_legacy_group; } namespace session::config { @@ -29,22 +32,23 @@ class ConvoInfoVolatile; /// included, but will be 0 if no messages are read. /// u - will be present and set to 1 if this conversation is specifically marked unread. /// -/// o - open group conversations. Each key is: BASE_URL + '\0' + LC_ROOM_NAME + '\0' + -/// SERVER_PUBKEY (in bytes). Note that room name is *always* lower-cased here (so that clients -/// with the same room but with different cases will always set the same key). Values are dicts -/// with keys: -/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always included, -/// but will be 0 if no messages are read. -/// u - will be present and set to 1 if this conversation is specifically marked unread. +/// o - community conversations. This is a nested dict where the outer keys are the BASE_URL of the +/// community and the outer value is a dict containing: +/// - `#` -- the 32-byte server pubkey +/// - `R` -- dict of rooms on the server; each key is the lower-case room name, value is a dict +/// containing keys: +/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always +/// included, but will be 0 if no messages are read. +/// u - will be present and set to 1 if this conversation is specifically marked unread. /// -/// C - legacy closed group conversations. The key is the closed group identifier (which looks -/// indistinguishable from a Session ID, but isn't really a proper Session ID). Values are -/// dicts with keys: +/// C - legacy group conversations (aka closed groups). The key is the group identifier (which +/// looks indistinguishable from a Session ID, but isn't really a proper Session ID). Values +/// are dicts with keys: /// r - the unix timestamp (integer milliseconds) of the last-read message. Always included, /// but will be 0 if no messages are read. /// u - will be present and set to 1 if this conversation is specifically marked unread. /// -/// c - reserved for future tracking of new closed group conversations. +/// c - reserved for future tracking of new group conversations. namespace convo { @@ -71,96 +75,34 @@ namespace convo { friend class session::config::ConvoInfoVolatile; }; - struct open_group : base { - // 267 = len('https://') + 253 (max valid DNS name length) + len(':XXXXX') - static constexpr size_t MAX_URL = 267, MAX_ROOM = 64; + struct community : config::community, base { - std::string_view base_url() const; // Accesses the base url (i.e. not including room or - // pubkey). Always lower-case. - std::string_view room() - const; // Accesses the room name, always in lower-case. (Note that the - // actual open group info might not be lower-case; it is just in - // the open group convo where we force it lower-case). - ustring_view pubkey() const; // Accesses the server pubkey (32 bytes). - std::string pubkey_hex() const; // Accesses the server pubkey as hex (64 hex digits). - - open_group() = default; - - // Constructs an empty open_group convo struct from url, room, and pubkey. `base_url` and - // `room` will be lower-cased if not already (they do not have to be passed lower-case). - // pubkey is 32 bytes. - open_group(std::string_view base_url, std::string_view room, ustring_view pubkey); - - // Same as above, but takes pubkey as a hex string. - open_group(std::string_view base_url, std::string_view room, std::string_view pubkey_hex); - - // Takes a combined room URL (e.g. https://whatever.com/r/Room?public_key=01234....), either - // new style (with /r/) or old style (without /r/). Note that the URL gets canonicalized so - // the resulting `base_url()` and `room()` values may not be exactly equal to what is given. - // - // See also `parse_full_url` which does the same thing but returns it in pieces rather than - // constructing a new `open_group` object. - explicit open_group(std::string_view full_url); + using config::community::community; // Internal ctor/method for C API implementations: - open_group(const struct convo_info_volatile_open& c); // From c struct - void into(convo_info_volatile_open& c) const; // Into c struct - - // Replaces the baseurl/room/pubkey of this object. Note that changing this and then giving - // it to `set` will end up inserting a *new* record but not removing the *old* one (you need - // to erase first to do that). - void set_server(std::string_view base_url, std::string_view room, ustring_view pubkey); - void set_server( - std::string_view base_url, std::string_view room, std::string_view pubkey_hex); - void set_server(std::string_view full_url); - - // Loads the baseurl/room/pubkey of this object from an encoded key. Throws - // std::invalid_argument if the encoded key does not look right. - void load_encoded_key(std::string key); - - // Takes a base URL as input and returns it in canonical form. This involves doing things - // like lower casing it and removing redundant ports (e.g. :80 when using http://). - static std::string canonical_url(std::string_view url); - - // Takes a full room URL, splits it up into canonical url (see above), lower-case room - // token, and server pubkey. We take both the deprecated form (e.g. - // https://example.com/SomeRoom?public_key=...) and new form - // (https://example.com/r/SomeRoom?public_key=...). The public_key is typically specified - // in hex (64 digits), but we also accept unpadded base64 (43 chars) and base32z (52 chars) - // encodings (for slightly shorter URLs). - static std::tuple parse_full_url( - std::string_view full_url); - - private: - std::string key; - size_t url_size = 0; + community(const convo_info_volatile_community& c); // From c struct + void into(convo_info_volatile_community& c) const; // Into c struct friend class session::config::ConvoInfoVolatile; - - // Returns the key value we use in the stored dict for this open group, i.e. - // lc(URL) + lc(NAME) + PUBKEY_BYTES. - static std::string make_key( - std::string_view base_url, std::string_view room, std::string_view pubkey_hex); - static std::string make_key( - std::string_view base_url, std::string_view room, ustring_view pubkey); + friend struct session::config::comm_iterator_helper; }; - struct legacy_closed_group : base { + struct legacy_group : base { std::string id; // in hex, indistinguishable from a Session ID - // Constructs an empty legacy_closed_group from a quasi-session_id - explicit legacy_closed_group(std::string&& group_id); - explicit legacy_closed_group(std::string_view group_id); + // Constructs an empty legacy_group from a quasi-session_id + explicit legacy_group(std::string&& group_id); + explicit legacy_group(std::string_view group_id); // Internal ctor/method for C API implementations: - legacy_closed_group(const struct convo_info_volatile_legacy_closed& c); // From c struct - void into(convo_info_volatile_legacy_closed& c) const; // Into c struct + legacy_group(const struct convo_info_volatile_legacy_group& c); // From c struct + void into(convo_info_volatile_legacy_group& c) const; // Into c struct private: friend class session::config::ConvoInfoVolatile; }; - using any = std::variant; + using any = std::variant; } // namespace convo class ConvoInfoVolatile : public ConfigBase { @@ -186,33 +128,49 @@ class ConvoInfoVolatile : public ConfigBase { const char* encryption_domain() const override { return "ConvoInfoVolatile"; } + /// Our pruning ages. We ignore added conversations that are more than PRUNE_LOW before now, + /// and we active remove (when doing a new push) any conversations that are more than PRUNE_HIGH + /// before now. Clients can mostly ignore these and just add all conversations; the class just + /// transparently ignores (or removes) pruned values. + static constexpr auto PRUNE_LOW = 30 * 24h; + static constexpr auto PRUNE_HIGH = 45 * 24h; + + /// Overrides push() to prune stale last-read values before we do the push. + std::pair push() override; + /// Looks up and returns a contact by session ID (hex). Returns nullopt if the session ID was /// not found, otherwise returns a filled out `convo::one_to_one`. std::optional get_1to1(std::string_view session_id) const; - /// Looks up and returns an open group conversation. Takes the base URL, room name (case - /// insensitive), and pubkey (in hex). Retuns nullopt if the open group was not found, - /// otherwise a filled out `convo::open_group`. - std::optional get_open( - std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const; + /// Looks up and returns a community conversation. Takes the base URL and room name (case + /// insensitive). Retuns nullopt if the community was not found, otherwise a filled out + /// `convo::community`. + std::optional get_community( + std::string_view base_url, std::string_view room) const; - /// Same as above, but takes the pubkey as bytes instead of hex - std::optional get_open( - std::string_view base_url, std::string_view room, ustring_view pubkey) const; - - /// Looks up and returns a legacy closed group conversation by ID. The ID looks like a hex - /// Session ID, but isn't really a Session ID. Returns nullopt if there is no record of the - /// closed group conversation. - std::optional get_legacy_closed(std::string_view pubkey_hex) const; + /// Looks up and returns a legacy group conversation by ID. The ID looks like a hex Session ID, + /// but isn't really a Session ID. Returns nullopt if there is no record of the group + /// conversation. + std::optional get_legacy_group(std::string_view pubkey_hex) const; /// These are the same as the above methods (without "_or_construct" in the name), except that /// when the conversation doesn't exist a new one is created, prefilled with the pubkey/url/etc. convo::one_to_one get_or_construct_1to1(std::string_view session_id) const; - convo::open_group get_or_construct_open( + convo::legacy_group get_or_construct_legacy_group(std::string_view pubkey_hex) const; + + /// This is similar to get_community, except that it also takes the pubkey; the community is + /// looked up by the url & room; if not found, it is constructed using room, url, and pubkey; if + /// it *is* found, then it will always have the *input* pubkey, not the stored pubkey + /// (effectively the provided pubkey replaces the stored one in the returned object; this is not + /// applied to storage, however, unless/until the instance is given to `set()`). + /// + /// Note, however, that when modifying an object like this the update is *only* applied to the + /// returned object; like other fields, it is not updated in the internal state unless/until + /// that community instance is passed to `set()`. + convo::community get_or_construct_community( std::string_view base_url, std::string_view room, std::string_view pubkey_hex) const; - convo::open_group get_or_construct_open( + convo::community get_or_construct_community( std::string_view base_url, std::string_view room, ustring_view pubkey) const; - convo::legacy_closed_group get_or_construct_legacy_closed(std::string_view pubkey_hex) const; /// Inserts or replaces existing conversation info. For example, to update a 1-to-1 /// conversation last read time you would do: @@ -222,31 +180,35 @@ class ConvoInfoVolatile : public ConfigBase { /// conversations.set(info); /// void set(const convo::one_to_one& c); - void set(const convo::legacy_closed_group& c); - void set(const convo::open_group& c); + void set(const convo::legacy_group& c); + void set(const convo::community& c); void set(const convo::any& c); // Variant which can be any of the above protected: void set_base(const convo::base& c, DictFieldProxy& info); + // Drills into the nested dicts to access community details; if the second argument is + // non-nullptr then it will be set to the community's pubkey, if it exists. + DictFieldProxy community_field( + const convo::community& og, ustring_view* get_pubkey = nullptr) const; + public: /// Removes a one-to-one conversation. Returns true if found and removed, false if not present. bool erase_1to1(std::string_view pubkey); - /// Removes an open group conversation record. Returns true if found and removed, false if not - /// present. Arguments are the same as `get_open`. - bool erase_open(std::string_view base_url, std::string_view room, std::string_view pubkey_hex); - bool erase_open(std::string_view base_url, std::string_view room, ustring_view pubkey); + /// Removes a community conversation record. Returns true if found and removed, false if not + /// present. Arguments are the same as `get_community`. + bool erase_community(std::string_view base_url, std::string_view room); - /// Removes a legacy closed group conversation. Returns true if found and removed, false if not + /// Removes a legacy group conversation. Returns true if found and removed, false if not /// present. - bool erase_legacy_closed(std::string_view pubkey_hex); + bool erase_legacy_group(std::string_view pubkey_hex); /// Removes a conversation taking the convo::whatever record (rather than the pubkey/url). bool erase(const convo::one_to_one& c); - bool erase(const convo::open_group& c); - bool erase(const convo::legacy_closed_group& c); + bool erase(const convo::community& c); + bool erase(const convo::legacy_group& c); bool erase(const convo::any& c); // Variant of any of them @@ -261,11 +223,10 @@ class ConvoInfoVolatile : public ConfigBase { /// Returns the number of conversations (of any type). size_t size() const; - /// Returns the number of 1-to-1, open group, and legacy closed group conversations, - /// respectively. + /// Returns the number of 1-to-1, community, and legacy group conversations, respectively. size_t size_1to1() const; - size_t size_open() const; - size_t size_legacy_closed() const; + size_t size_communities() const; + size_t size_legacy_groups() const; /// Returns true if the conversation list is empty. bool empty() const { return size() == 0; } @@ -276,9 +237,9 @@ class ConvoInfoVolatile : public ConfigBase { /// for (auto& convo : conversations) { /// if (auto* dm = std::get_if(&convo)) { /// // use dm->session_id, dm->last_read, etc. - /// } else if (auto* og = std::get_if(&convo)) { + /// } else if (auto* og = std::get_if(&convo)) { /// // use og->base_url, og->room, om->last_read, etc. - /// } else if (auto* lcg = std::get_if(&convo)) { + /// } else if (auto* lcg = std::get_if(&convo)) { /// // use lcg->id, lcg->last_read /// } /// } @@ -302,8 +263,8 @@ class ConvoInfoVolatile : public ConfigBase { /// /// Alternatively, you can use the first version with two loops: the first loop through all /// converations doesn't erase but just builds a vector of IDs to erase, then the second loops - /// through that vector calling `erase_1to1()`/`erase_open()`/`erase_legacy_closed()` for each - /// one. + /// through that vector calling `erase_1to1()`/`erase_community()`/`erase_legacy_group()` for + /// each one. /// iterator begin() const { return iterator{data}; } iterator end() const { return iterator{}; } @@ -313,12 +274,11 @@ class ConvoInfoVolatile : public ConfigBase { /// Returns an iterator that iterates only through one type of conversations subtype_iterator begin_1to1() const { return {data}; } - subtype_iterator begin_open() const { return {data}; } - subtype_iterator begin_legacy_closed() const { return {data}; } + subtype_iterator begin_communities() const { return {data}; } + subtype_iterator begin_legacy_groups() const { return {data}; } using iterator_category = std::input_iterator_tag; - using value_type = - std::variant; + using value_type = std::variant; using reference = value_type&; using pointer = value_type*; using difference_type = std::ptrdiff_t; @@ -326,15 +286,15 @@ class ConvoInfoVolatile : public ConfigBase { struct iterator { protected: std::shared_ptr _val; - std::optional _it_11, _end_11, _it_open, _end_open, _it_lclosed, - _end_lclosed; + std::optional _it_11, _end_11, _it_lgroup, _end_lgroup; + std::optional _it_comm; void _load_val(); iterator() = default; // Constructs an end tombstone explicit iterator( const DictFieldRoot& data, bool oneto1 = true, - bool open = true, - bool closed = true); + bool communities = true, + bool legacy_groups = true); friend class ConvoInfoVolatile; public: @@ -358,8 +318,8 @@ class ConvoInfoVolatile : public ConfigBase { iterator( data, std::is_same_v, - std::is_same_v, - std::is_same_v) {} + std::is_same_v, + std::is_same_v) {} friend class ConvoInfoVolatile; public: diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.h new file mode 100644 index 000000000..c98653f13 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.h @@ -0,0 +1,7 @@ +#pragma once + +typedef enum CONVO_EXPIRATION_MODE { + CONVO_EXPIRATION_NONE = 0, + CONVO_EXPIRATION_AFTER_SEND = 1, + CONVO_EXPIRATION_AFTER_READ = 2, +} CONVO_EXPIRATION_MODE; diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.hpp new file mode 100644 index 000000000..4c040b740 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/expiring.hpp @@ -0,0 +1,8 @@ +#pragma once +#include + +namespace session::config { + +enum class expiration_mode : int8_t { none = 0, after_send = 1, after_read = 2 }; + +} diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp index e96a12ab0..394617c0c 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp @@ -8,7 +8,7 @@ enum class Namespace : std::int16_t { UserProfile = 2, Contacts = 3, ConvoInfoVolatile = 4, - ClosedGroupInfo = 11, + UserGroups = 5, }; } // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h index dc9887dd8..590df2117 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h @@ -7,13 +7,12 @@ extern "C" { #include typedef struct user_profile_pic { - // Null-terminated C string containing the uploaded URL of the pic. Will be NULL if there is no - // profile pic. - const char* url; - // The profile pic decryption key, in bytes. This is a byte buffer of length `keylen`, *not* a - // null-terminated C string. Will be NULL if there is no profile pic. - const unsigned char* key; - size_t keylen; + // Null-terminated C string containing the uploaded URL of the pic. Will be length 0 if there + // is no profile pic. + char url[224]; + // The profile pic decryption key, in bytes. This is a byte buffer of length 32, *not* a + // null-terminated C string. This is only valid when there is a url (i.e. url has strlen > 0). + unsigned char key[32]; } user_profile_pic; #ifdef __cplusplus diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp index 0a076ab97..d450d074f 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp @@ -1,39 +1,57 @@ #pragma once +#include + #include "session/types.hpp" namespace session::config { -// Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end -// of the string view: that is, it views into a full std::string). +// Profile pic info. struct profile_pic { - private: - std::string url_; - ustring key_; + static constexpr size_t MAX_URL_LENGTH = 223; - public: - std::string_view url; - ustring_view key; + std::string url; + ustring key; + + static void check_key(ustring_view key) { + if (!(key.empty() || key.size() == 32)) + throw std::invalid_argument{"Invalid profile pic key: 32 bytes required"}; + } // Default constructor, makes an empty profile pic profile_pic() = default; - // Constructs from string views: the values must stay alive for the duration of the profile_pic - // instance. (If not, use `set_url`/`set_key` or the rvalue-argument constructor instead). - profile_pic(std::string_view url, ustring_view key) : url{url}, key{key} {} + // Constructs from a URL and key. Key must be empty or 32 bytes. + profile_pic(std::string_view url, ustring_view key) : url{url}, key{key} { + check_key(this->key); + } - // Constructs from temporary strings; the strings are stored/managed internally - profile_pic(std::string&& url, ustring&& key) : - url_{std::move(url)}, key_{std::move(key)}, url{url_}, key{key_} {} + // Constructs from a string/ustring pair moved into the constructor + profile_pic(std::string&& url, ustring&& key) : url{std::move(url)}, key{std::move(key)} { + check_key(this->key); + } - // Returns true if either url or key are empty - bool empty() const { return url.empty() || key.empty(); } + // Returns true if either url or key are empty (or invalid) + bool empty() const { return url.empty() || key.size() != 32; } - // Sets the url or key to a temporary value that needs to be copied and owned by this - // profile_pic object. (This is only needed when the source string may not outlive the - // profile_pic object; if it does, the `url` or `key` can be assigned to directly). - void set_url(std::string url); - void set_key(ustring key); + // Clears the current url/key, if set. This is just a shortcut for calling `.clear()` on each + // of them. + void clear() { + url.clear(); + key.clear(); + } + + // The object in boolean context is true if url and key are both set, i.e. the opposite of + // `empty()`. + explicit operator bool() const { return !empty(); } + + // Sets and validates the key. The key can be empty, or 32 bytes. This is almost the same as + // just setting `.key` directly, except that it will throw if the provided key is invalid (i.e. + // neither empty nor 32 bytes). + void set_key(ustring new_key) { + check_key(new_key); + key = std::move(new_key); + } }; } // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.h new file mode 100644 index 000000000..a4897da6f --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.h @@ -0,0 +1,181 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "base.h" +#include "util.h" + +typedef struct ugroups_legacy_group_info { + char session_id[67]; // in hex; 66 hex chars + null terminator. + + char name[101]; // Null-terminated C string (human-readable). Max length is 511. Will always + // be set (even if an empty string). + + bool have_enc_keys; // Will be true if we have an encryption keypair, false if not. + unsigned char enc_pubkey[32]; // If `have_enc_keys`, this is the 32-byte pubkey + unsigned char enc_seckey[32]; // If `have_enc_keys`, this is the 32-byte secret key + + int64_t disappearing_timer; // Minutes. 0 == disabled. + bool hidden; // true if hidden from the convo list + int priority; // pinned message priority; 0 = unpinned, larger means pinned higher (i.e. higher + // priority conversations come first). +} ugroups_legacy_group_info; + +typedef struct ugroups_community_info { + char base_url[268]; // null-terminated (max length 267), normalized (i.e. always lower-case, + // only has port if non-default, has trailing / removed) + char room[65]; // null-terminated (max length 64); this is case-preserving (i.e. can be + // "SomeRoom" instead of "someroom". Note this is different from volatile + // info (that one is always forced lower-cased). + unsigned char pubkey[32]; // 32 bytes (not terminated, can contain nulls) + + int priority; // pinned message priority; 0 = unpinned, larger means pinned higher (i.e. higher + // priority conversations come first). +} ugroups_community_info; + +int user_groups_init( + config_object** conf, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// Gets community conversation info into `comm`, if the community info was found. `base_url` and +/// `room` are null-terminated c strings; pubkey is 32 bytes. base_url will be +/// normalized/lower-cased; room is case-insensitive for the lookup: note that this may well return +/// a community info with a different room capitalization than the one provided to the call. +/// +/// Returns true if the community was found and `comm` populated; false otherwise. +bool user_groups_get_community( + const config_object* conf, + ugroups_community_info* comm, + const char* base_url, + const char* room) __attribute__((warn_unused_result)); + +/// Like the above, but if the community was not found, this constructs one that can be inserted. +/// `base_url` will be normalized in the returned object. `room` is a case-insensitive lookup key +/// for the room token. Note that it has subtle handling w.r.t its case: if an existing room is +/// found, you get back a record with the found case (which could differ in case from what you +/// provided). If you want to override to what you provided regardless of what is there you should +/// immediately set the name of the returned object to the case you prefer. If a *new* record is +/// constructed, however, it will match the room token case as given here. +/// +/// Note that this is all different from convo_info_volatile, which always forces the room token to +/// lower-case (because it does not preserve the case). +bool user_groups_get_or_construct_community( + const config_object* conf, + ugroups_community_info* comm, + const char* base_url, + const char* room, + unsigned const char* pubkey) __attribute__((warn_unused_result)); + +/// Fills `group` with the conversation info given a legacy group ID (specified as a null-terminated +/// hex string), if the conversation exists, and returns true. If the conversation does not exist +/// then `group` is left unchanged and false is returned. +bool user_groups_get_legacy_group( + const config_object* conf, ugroups_legacy_group_info* group, const char* id) + __attribute__((warn_unused_result)); + +/// Same as the above except that when the conversation does not exist, this sets all the group +/// fields to defaults and loads it with the given id. +/// +/// Returns true as long as it is given a valid legacy group group id (i.e. same format as a session +/// id). A false return is considered an error, and means the id was not a valid session id. +/// +/// This is the method that should usually be used to create or update a conversation, followed by +/// setting fields in the group, and then giving it to user_groups_set(). +bool user_groups_get_or_construct_legacy_group( + const config_object* conf, ugroups_legacy_group_info* group, const char* id) + __attribute__((warn_unused_result)); + +/// Adds or updates a conversation from the given group info +void user_groups_set_community(config_object* conf, const ugroups_community_info* group); +void user_groups_set_legacy_group(config_object* conf, const ugroups_legacy_group_info* group); + +/// Erases a conversation from the conversation list. Returns true if the conversation was found +/// and removed, false if the conversation was not present. You must not call this during +/// iteration; see details below. +bool user_groups_erase_community(config_object* conf, const char* base_url, const char* room); +bool user_groups_erase_legacy_group(config_object* conf, const char* group_id); + +/// Returns the number of conversations. +size_t user_groups_size(const config_object* conf); +/// Returns the number of conversations of the specific type. +size_t user_groups_size_communities(const config_object* conf); +size_t user_groups_size_legacy_groups(const config_object* conf); + +/// Functions for iterating through the entire conversation list. Intended use is: +/// +/// ugroups_community_info c2; +/// ugroups_legacy_group_info c3; +/// user_groups_iterator *it = user_groups_iterator_new(my_groups); +/// for (; !user_groups_iterator_done(it); user_groups_iterator_advance(it)) { +/// if (user_groups_it_is_community(it, &c2)) { +/// // use c2.whatever +/// } else if (user_groups_it_is_legacy_group(it, &c3)) { +/// // use c3.whatever +/// } +/// } +/// user_groups_iterator_free(it); +/// +/// It is permitted to modify records (e.g. with a call to one of the `user_groups_set_*` +/// functions) and add records while iterating. +/// +/// If you need to remove while iterating then usage is slightly different: you must advance the +/// iteration by calling either user_groups_iterator_advance if not deleting, or +/// user_groups_iterator_erase to erase and advance. Usage looks like this: +/// +/// ugroups_community_info comm; +/// ugroups_iterator *it = ugroups_iterator_new(my_groups); +/// while (!user_groups_iterator_done(it)) { +/// if (user_groups_it_is_community(it, &comm)) { +/// bool should_delete = /* ... */; +/// if (should_delete) +/// user_groups_iterator_erase(it); +/// else +/// user_groups_iterator_advance(it); +/// } else { +/// user_groups_iterator_advance(it); +/// } +/// } +/// user_groups_iterator_free(it); +/// + +typedef struct user_groups_iterator user_groups_iterator; + +// Starts a new iterator that iterates over all conversations. +user_groups_iterator* user_groups_iterator_new(const config_object* conf); + +// The same as `user_groups_iterator_new` except that this iterates *only* over one type of +// conversation. You still need to use `user_groups_it_is_community` (or the alternatives) +// to load the data in each pass of the loop. (You can, however, safely ignore the bool return +// value of the `it_is_whatever` function: it will always be true for the particular type being +// iterated over). +user_groups_iterator* user_groups_iterator_new_communities(const config_object* conf); +user_groups_iterator* user_groups_iterator_new_legacy_groups(const config_object* conf); + +// Frees an iterator once no longer needed. +void user_groups_iterator_free(user_groups_iterator* it); + +// Returns true if iteration has reached the end. +bool user_groups_iterator_done(user_groups_iterator* it); + +// Advances the iterator. +void user_groups_iterator_advance(user_groups_iterator* it); + +// If the current iterator record is a community conversation this sets the details into `c` and +// returns true. Otherwise it returns false. +bool user_groups_it_is_community(user_groups_iterator* it, ugroups_community_info* c); + +// If the current iterator record is a legacy group conversation this sets the details into +// `c` and returns true. Otherwise it returns false. +bool user_groups_it_is_legacy_group(user_groups_iterator* it, ugroups_legacy_group_info* c); + +// Erases the current group while advancing the iterator to the next group in the iteration. +void user_groups_iterator_erase(config_object* conf, user_groups_iterator* it); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.hpp new file mode 100644 index 000000000..2feafa693 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_groups.hpp @@ -0,0 +1,335 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "base.hpp" +#include "community.hpp" +#include "namespaces.hpp" + +extern "C" { +struct ugroups_legacy_group_info; +struct ugroups_community_info; +} + +namespace session::config { + +/// keys used in this config, either currently or in the past (so that we don't reuse): +/// +/// C - dict of legacy groups; within this dict each key is the group pubkey (binary, 33 bytes) and +/// value is a dict containing keys: +/// +/// n - name (string). Always set, even if empty. +/// k - encryption public key (32 bytes). Optional. +/// K - encryption secret key (32 bytes). Optional. +/// m - set of member session ids (each 33 bytes). +/// a - set of admin session ids (each 33 bytes). +/// E - disappearing messages duration, in minutes, > 0. Omitted if disappearing messages is +/// disabled. (Note that legacy groups only support expire after-read) +/// h - hidden: 1 if the conversation has been removed from the conversation list, omitted if +/// visible. +/// + - the conversation priority, for pinned messages. Omitted means not pinned; otherwise an +/// integer value >0, where a higher priority means the conversation is meant to appear +/// earlier in the pinned conversation list. +/// +/// o - dict of communities (AKA open groups); within this dict (which deliberately has the same +/// layout as convo_info_volatile) each key is the SOGS base URL (in canonical form), and value +/// is a dict of: +/// +/// # - server pubkey +/// R - dict of rooms on the server. Each key is the *lower-case* room name; each value is: +/// n - the room name as is commonly used, i.e. with possible capitalization (if +/// appropriate). For instance, a room name SudokuSolvers would be "sudokusolvers" in +/// the outer key, with the capitalization variation in use ("SudokuSolvers") in this +/// key. This key is *always* present (to keep the room dict non-empty). +/// + - the conversation priority, for pinned messages. Omitted means not pinned; otherwise +/// an integer value >0, where a higher priority means the conversation is meant to +/// appear earlier in the pinned conversation list. +/// +/// c - reserved for future storage of new-style group info. + +/// Struct containing legacy group info (aka "closed groups"). +struct legacy_group_info { + static constexpr size_t NAME_MAX_LENGTH = 100; // in bytes; name will be truncated if exceeded + + std::string session_id; // The legacy group "session id" (33 bytes). + std::string name; // human-readable; this should normally always be set, but in theory could be + // set to an empty string. + ustring enc_pubkey; // bytes (32 or empty) + ustring enc_seckey; // bytes (32 or empty) + std::chrono::minutes disappearing_timer{0}; // 0 == disabled. + bool hidden = false; // true if the conversation is hidden from the convo list + int priority = 0; // The priority; 0 means unpinned, larger means pinned higher (i.e. + // higher priority conversations come first). + + /// Constructs a new legacy group info from an id (which must look like a session_id). Throws + /// if id is invalid. + explicit legacy_group_info(std::string sid); + + // Accesses the session ids (in hex) of members of this group. The key is the hex session_id; + // the value indicates whether the member is an admin (true) or not (false). + const std::map& members() const { return members_; } + + // Returns a pair of the number of admins, and regular members of this group. (If all you want + // is the overall number just use `.members().size()` instead). + std::pair counts() const; + + // Adds a member (by session id and admin status) to this group. Returns true if the member was + // inserted or changed admin status, false if the member already existed. Throws + // std::invalid_argument if the given session id is invalid. + bool insert(std::string session_id, bool admin); + + // Removes a member (by session id) from this group. Returns true if the member was + // removed, false if the member was not present. + bool erase(const std::string& session_id); + + // Internal ctor/method for C API implementations: + legacy_group_info(const struct ugroups_legacy_group_info& c); // From c struct + void into(struct ugroups_legacy_group_info& c) const; // Into c struct + + private: + // session_id => (is admin) + std::map members_; + + friend class UserGroups; + + void load(const dict& info_dict); +}; + +/// Community (aka open group) info +struct community_info : community { + // Note that *changing* url/room/pubkey and then doing a set inserts a new room under the given + // url/room/pubkey, it does *not* update an existing room. + + // See community_base (comm_base.hpp) for common constructors + using community::community; + + // Internal ctor/method for C API implementations: + community_info(const struct ugroups_community_info& c); // From c struct + void into(ugroups_community_info& c) const; // Into c struct + + int priority = 0; // The priority; 0 means unpinned, larger means pinned higher (i.e. + // higher priority conversations come first). + + private: + void load(const dict& info_dict); + + friend class UserGroups; + friend class comm_iterator_helper; +}; + +using any_group_info = std::variant; + +class UserGroups : public ConfigBase { + + public: + // No default constructor + UserGroups() = delete; + + /// Constructs a user group list from existing data (stored from `dump()`) and the user's + /// secret key for generating the data encryption key. To construct a blank list (i.e. with no + /// pre-existing dumped data to load) pass `std::nullopt` as the second argument. + /// + /// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt the + /// data when pushing/pulling from the swarm. This can either be the full 64-byte value (which + /// is technically the 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of + /// the secret key. + /// + /// \param dumped - either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + UserGroups(ustring_view ed25519_secretkey, std::optional dumped); + + Namespace storage_namespace() const override { return Namespace::UserGroups; } + + const char* encryption_domain() const override { return "UserGroups"; } + + /// Looks up and returns a community (aka open group) conversation. Takes the base URL and room + /// token (case insensitive). Retuns nullopt if the open group was not found, otherwise a + /// filled out `community_info`. Note that the `room` argument here is case-insensitive, but + /// the returned value will be the room as stored in the object (i.e. it may have a different + /// case from the requested `room` value). + std::optional get_community( + std::string_view base_url, std::string_view room) const; + + /// Looks up and returns a legacy group by group ID (hex, looks like a Session ID). Returns + /// nullopt if the group was not found, otherwise returns a filled out `legacy_group_info`. + std::optional get_legacy_group(std::string_view pubkey_hex) const; + + /// Same as `get_community`, except if the community isn't found a new blank one is created for + /// you, prefilled with the url/room/pubkey. + /// + /// Note that `room` and `pubkey` have special handling: + /// - `room` is case-insensitive for the lookup: if a matching room is found then the returned + /// value reflects the room case of the existing record, which is not necessarily the same as + /// the `room` argument given here (to force a case change, set it within the returned + /// object). + /// - `pubkey` is not used to find an existing community, but if the community found has a + /// *different* pubkey from the one given then the returned record has its pubkey updated in + /// the return instance (note that this changed value is not committed to storage, however, + /// until the instance is passed to `set()`). For the string_view version the pubkey is + /// accepted as hex, base32z, or base64. + community_info get_or_construct_community( + std::string_view base_url, + std::string_view room, + std::string_view pubkey_encoded) const; + community_info get_or_construct_community( + std::string_view base_url, std::string_view room, ustring_view pubkey) const; + + /// Gets or constructs a blank legacy_group_info for the given group id. + legacy_group_info get_or_construct_legacy_group(std::string_view pubkey_hex) const; + + /// Inserts or replaces existing group info. For example, to update the info for a community + /// you would do: + /// + /// auto info = conversations.get_or_construct_community(some_session_id); + /// info.last_read = new_unix_timestamp; + /// conversations.set(info); + /// + void set(const community_info& info); + void set(const legacy_group_info& info); + /// Takes a variant of either group type to set: + void set(const any_group_info& info); + + protected: + // Drills into the nested dicts to access open group details + DictFieldProxy community_field( + const community_info& og, ustring_view* get_pubkey = nullptr) const; + + public: + /// Removes a community group. Returns true if found and removed, false if not present. + /// Arguments are the same as `get_community`. + bool erase_community(std::string_view base_url, std::string_view room); + + /// Removes a legacy group conversation. Returns true if found and removed, false if not + /// present. + bool erase_legacy_group(std::string_view pubkey_hex); + + /// Removes a conversation taking the community_info or legacy_group_info instance (rather than + /// the pubkey/url) for convenience. + bool erase(const community_info& g); + bool erase(const legacy_group_info& c); + bool erase(const any_group_info& info); + + struct iterator; + + /// This works like erase, but takes an iterator to the group to remove. The element is removed + /// and the iterator to the next element after the removed one is returned. This is intended + /// for use where elements are to be removed during iteration: see below for an example. + iterator erase(iterator it); + + /// Returns the number of groups (of any type). + size_t size() const; + + /// Returns the number of communities + size_t size_communities() const; + + /// Returns the number of legacy groups + size_t size_legacy_groups() const; + + /// Returns true if the group list is empty. + bool empty() const { return size() == 0; } + + /// Iterators for iterating through all groups. Typically you access this implicit via a + /// for loop over the `UserGroups` object: + /// + /// for (auto& group : usergroups) { + /// if (auto* comm = std::get_if(&group)) { + /// // use comm->name, comm->priority, etc. + /// } else if (auto* lg = std::get_if(&convo)) { + /// // use lg->session_id, lg->hidden, etc. + /// } + /// } + /// + /// This iterates through all groups in sorted order (sorted first by convo type, then by + /// id within the type). + /// + /// It is permitted to modify and add records while iterating (e.g. by modifying one of the + /// `comm`/`lg` objects and then calling set()). + /// + /// If you need to erase the current conversation during iteration then care is required: you + /// need to advance the iterator via the iterator version of erase when erasing an element + /// rather than incrementing it regularly. For example: + /// + /// for (auto it = conversations.begin(); it != conversations.end(); ) { + /// if (should_remove(*it)) + /// it = converations.erase(it); + /// else + /// ++it; + /// } + /// + /// Alternatively, you can use the first version with two loops: the first loop through all + /// converations doesn't erase but just builds a vector of IDs to erase, then the second loops + /// through that vector calling `erase_1to1()`/`erase_open()`/`erase_legacy_group()` for each + /// one. + /// + iterator begin() const { return iterator{data}; } + iterator end() const { return iterator{}; } + + template + struct subtype_iterator; + + /// Returns an iterator that iterates only through one type of conversations. (The regular + /// `.end()` iterator is valid for testing the end of these iterations). + subtype_iterator begin_communities() const { return {data}; } + subtype_iterator begin_legacy_groups() const { return {data}; } + + using iterator_category = std::input_iterator_tag; + using value_type = std::variant; + using reference = value_type&; + using pointer = value_type*; + using difference_type = std::ptrdiff_t; + + struct iterator { + protected: + std::shared_ptr _val; + std::optional _it_comm; + std::optional _it_legacy, _end_legacy; + void _load_val(); + iterator() = default; // Constructs an end tombstone + explicit iterator( + const DictFieldRoot& data, bool communities = true, bool legacy_closed = true); + friend class UserGroups; + + public: + bool operator==(const iterator& other) const; + bool operator!=(const iterator& other) const { return !(*this == other); } + bool done() const; // Equivalent to comparing against the end iterator + any_group_info& operator*() const { return *_val; } + any_group_info* operator->() const { return _val.get(); } + iterator& operator++(); + iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; + + template + struct subtype_iterator : iterator { + protected: + subtype_iterator(const DictFieldRoot& data) : + iterator( + data, + std::is_same_v, + std::is_same_v) {} + friend class UserGroups; + + public: + GroupType& operator*() const { return std::get(*_val); } + GroupType* operator->() const { return &std::get(*_val); } + subtype_iterator& operator++() { + iterator::operator++(); + return *this; + } + subtype_iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; +}; + +} // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp index cb3b1eb32..bcc8afa95 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp @@ -44,9 +44,9 @@ class UserProfile final : public ConfigBase { /// Sets the user profile name; if given an empty string then the name is removed. void set_name(std::string_view new_name); - /// Gets the user's current profile pic URL and decryption key. Returns nullptr for *both* - /// values if *either* value is unset or empty in the config data. - std::optional get_profile_pic() const; + /// Gets the user's current profile pic URL and decryption key. The returned object will + /// evaluate as false if the URL and/or key are not set. + profile_pic get_profile_pic() const; /// Sets the user's current profile pic to a new URL and decryption key. Clears both if either /// one is empty. diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/version.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/version.h new file mode 100644 index 000000000..5574fdecc --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/version.h @@ -0,0 +1,19 @@ +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/// libsession-util version triplet (major, minor, patch) +extern const uint16_t LIBSESSION_UTIL_VERSION[3]; + +/// Printable full libsession-util name and version string, such as `libsession-util v0.1.2-release` +/// for a tagged release or `libsession-util v0.1.2-7f144eb5` for an untagged build. +extern const char* LIBSESSION_UTIL_VERSION_FULL; + +/// Just the version component as a string, e.g. `v0.1.2-release`. +extern const char* LIBSESSION_UTIL_VERSION_STR; + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index 7ac0ad123..b0c22ea07 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -373,7 +373,7 @@ public extension ClosedGroupControlMessage.Kind { let addedMemberNames: [String] = memberIds .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .legacyClosedGroup) + Profile.truncated(id: $0, threadVariant: .legacyGroup) } return String( @@ -396,7 +396,7 @@ public extension ClosedGroupControlMessage.Kind { let removedMemberNames: [String] = memberIds.removing(userPublicKey) .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .legacyClosedGroup) + Profile.truncated(id: $0, threadVariant: .legacyGroup) } let format: String = (removedMemberNames.count > 1 ? "GROUP_MEMBERS_REMOVED".localized() : diff --git a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift index c6172c79e..2e67ea96e 100644 --- a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift @@ -15,6 +15,8 @@ public final class SharedConfigMessage: ControlMessage { public var seqNo: Int64 public var data: Data + /// SharedConfigMessages should last for 30 days rather than the standard 14 + public override var ttl: UInt64 { 30 * 24 * 60 * 60 * 1000 } public override var isSelfSendValid: Bool { true } // MARK: - Kind @@ -23,14 +25,14 @@ public final class SharedConfigMessage: ControlMessage { case userProfile case contacts case convoInfoVolatile - case groups + case userGroups public var description: String { switch self { case .userProfile: return "userProfile" case .contacts: return "contacts" case .convoInfoVolatile: return "convoInfoVolatile" - case .groups: return "groups" + case .userGroups: return "userGroups" } } } @@ -82,7 +84,7 @@ public final class SharedConfigMessage: ControlMessage { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } }(), seqNo: sharedConfigMessage.seqno, @@ -98,7 +100,7 @@ public final class SharedConfigMessage: ControlMessage { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } }(), seqno: self.seqNo, @@ -135,7 +137,7 @@ public extension SharedConfigMessage.Kind { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } } } diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 476b868f4..a40b6ca5d 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -39,10 +39,10 @@ public extension Message { return .contact(publicKey: thread.id) - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: return .closedGroup(groupPublicKey: thread.id) - case .openGroup: + case .community: guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else { throw StorageError.objectNotFound } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 7dc1bbcc4..22643545e 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -361,7 +361,7 @@ public extension Message { let blindedUserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( threadId: openGroupId, - threadVariant: .openGroup + threadVariant: .community ) for (encodedEmoji, rawReaction) in reactions { if let decodedEmoji = encodedEmoji.removingPercentEncoding, diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 20b01e9e1..8f70c54c1 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -218,8 +218,8 @@ public extension VisibleMessage { recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId, groupPublicKey: try? interaction.thread .filter( - SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup || - SessionThread.Columns.variant == SessionThread.Variant.closedGroup + SessionThread.Columns.variant == SessionThread.Variant.legacyGroup || + SessionThread.Columns.variant == SessionThread.Variant.group ) .select(.id) .asRequest(of: String.self) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3dcf881fa..ead83d7ed 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -363,7 +363,7 @@ public enum OpenGroupAPI { requests: requestResponseType, using: dependencies ) - .flatMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> AnyPublisher in + .tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> CapabilitiesAndRoomResponse in let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) let maybeRoomResponse: Codable? = data .first(where: { key, _ in @@ -380,20 +380,15 @@ public enum OpenGroupAPI { let capabilities: Capabilities = maybeCapabilities?.body, let roomInfo: ResponseInfoType = maybeRoom?.responseInfo, let room: Room = maybeRoom?.body - else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + else { throw HTTPError.parsingFailed } - return Just(( + return ( info: info, data: ( capabilities: (info: capabilitiesInfo, data: capabilities), room: (info: roomInfo, data: room) ) - )) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + ) } .eraseToAnyPublisher() } @@ -434,7 +429,7 @@ public enum OpenGroupAPI { requests: requestResponseType, using: dependencies ) - .flatMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> in + .tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) let maybeRooms: HTTP.BatchSubResponse<[Room]>? = data .first(where: { key, _ in @@ -450,17 +445,12 @@ public enum OpenGroupAPI { let capabilities: Capabilities = maybeCapabilities?.body, let roomsInfo: ResponseInfoType = maybeRooms?.responseInfo, let rooms: [Room] = maybeRooms?.body - else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + else { throw HTTPError.parsingFailed } - return Just(( + return ( capabilities: (info: capabilitiesInfo, data: capabilities), rooms: (info: roomsInfo, data: rooms) - )) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + ) } .eraseToAnyPublisher() } @@ -957,15 +947,10 @@ public enum OpenGroupAPI { timeout: FileServerAPI.fileTimeout, using: dependencies ) - .flatMap { responseInfo, maybeData -> AnyPublisher<(ResponseInfoType, Data), Error> in - guard let data: Data = maybeData else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + .tryMap { responseInfo, maybeData -> (ResponseInfoType, Data) in + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } - return Just((responseInfo, data)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return (responseInfo, data) } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e12a0a1ec..063cbd3ce 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -230,7 +230,7 @@ public final class OpenGroupManager { // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an // inactive one but that won't matter as we then activate it - _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup) + _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .community) _ = try? SessionThread.filter(id: threadId).updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) if (try? OpenGroup.exists(db, id: threadId)) == false { @@ -249,67 +249,64 @@ public final class OpenGroupManager { OpenGroup.Columns.sequenceNumber.set(to: 0) ) - // We was to avoid blocking the db write thread so we dispatch the API call to a different thread + // We want to avoid blocking the db write thread so we dispatch the API call to a different thread // // Note: We don't do this after the db commit as it can fail (resulting in endless loading) - return Just(()) - .setFailureType(to: Error.self) - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: OpenGroupAPI.workQueue) - .flatMap { _ in - dependencies.storage - .readPublisherFlatMap { db in - // Note: The initial request for room info and it's capabilities should NOT be - // authenticated (this is because if the server requires blinding and the auth - // headers aren't blinded it will error - these endpoints do support unauthenticated - // retrieval so doing so prevents the error) - OpenGroupAPI - .capabilitiesAndRoom( - db, - for: roomToken, - on: targetServer, - using: dependencies - ) - } - } - .flatMap { response -> Future in - Future { resolver in - dependencies.storage.write { db in - // Enqueue a config sync job (have a newly added open group to sync) - if !calledFromConfigHandling { - ConfigurationSyncJob.enqueue(db) - } - - // Store the capabilities first - OpenGroupManager.handleCapabilities( + return Deferred { + dependencies.storage + .readPublisherFlatMap(receiveOn: OpenGroupAPI.workQueue) { db in + // Note: The initial request for room info and it's capabilities should NOT be + // authenticated (this is because if the server requires blinding and the auth + // headers aren't blinded it will error - these endpoints do support unauthenticated + // retrieval so doing so prevents the error) + OpenGroupAPI + .capabilitiesAndRoom( db, - capabilities: response.data.capabilities.data, - on: targetServer - ) - - // Then the room - try OpenGroupManager.handlePollInfo( - db, - pollInfo: OpenGroupAPI.RoomPollInfo(room: response.data.room.data), - publicKey: publicKey, for: roomToken, on: targetServer, - dependencies: dependencies - ) { - resolver(Result.success(())) - } + using: dependencies + ) + } + } + .receive(on: OpenGroupAPI.workQueue) + .flatMap { response -> Future in + Future { resolver in + dependencies.storage.write { db in + // Enqueue a config sync job (have a newly added open group to sync) + if !calledFromConfigHandling { + ConfigurationSyncJob.enqueue(db) + } + + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.data.capabilities.data, + on: targetServer + ) + + // Then the room + try OpenGroupManager.handlePollInfo( + db, + pollInfo: OpenGroupAPI.RoomPollInfo(room: response.data.room.data), + publicKey: publicKey, + for: roomToken, + on: targetServer, + dependencies: dependencies + ) { + resolver(Result.success(())) } } } - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Failed to join open group.") - } + } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Failed to join open group.") } - ) - .eraseToAnyPublisher() + } + ) + .eraseToAnyPublisher() } public func delete(_ db: Database, openGroupId: String, dependencies: OGMDependencies = OGMDependencies()) { @@ -961,14 +958,14 @@ public final class OpenGroupManager { // Try to retrieve the default rooms 8 times let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: OpenGroupAPI.workQueue) { db in OpenGroupAPI.capabilitiesAndRooms( db, on: OpenGroupAPI.defaultServer, using: dependencies ) } - .subscribe(on: OpenGroupAPI.workQueue) + .receive(on: OpenGroupAPI.workQueue) .retry(8) .map { response in dependencies.storage.writeAsync { db in diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index b10ef5fac..ed766ca8d 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -3716,7 +3716,7 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { case userProfile = 1 case contacts = 2 case convoInfoVolatile = 3 - case groups = 4 + case userGroups = 4 } private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind { @@ -3724,7 +3724,7 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } } @@ -3733,7 +3733,7 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { case .userProfile: return .userProfile case .contacts: return .contacts case .convoInfoVolatile: return .convoInfoVolatile - case .groups: return .groups + case .userGroups: return .userGroups } } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 3b649effc..6f209cb67 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -1623,7 +1623,7 @@ struct SessionProtos_SharedConfigMessage { case userProfile // = 1 case contacts // = 2 case convoInfoVolatile // = 3 - case groups // = 4 + case userGroups // = 4 init() { self = .userProfile @@ -1634,7 +1634,7 @@ struct SessionProtos_SharedConfigMessage { case 1: self = .userProfile case 2: self = .contacts case 3: self = .convoInfoVolatile - case 4: self = .groups + case 4: self = .userGroups default: return nil } } @@ -1644,7 +1644,7 @@ struct SessionProtos_SharedConfigMessage { case .userProfile: return 1 case .contacts: return 2 case .convoInfoVolatile: return 3 - case .groups: return 4 + case .userGroups: return 4 } } @@ -3343,6 +3343,6 @@ extension SessionProtos_SharedConfigMessage.Kind: SwiftProtobuf._ProtoNameProvid 1: .same(proto: "USER_PROFILE"), 2: .same(proto: "CONTACTS"), 3: .same(proto: "CONVO_INFO_VOLATILE"), - 4: .same(proto: "GROUPS"), + 4: .same(proto: "USER_GROUPS"), ] } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 2014274b0..429c10b14 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -277,7 +277,7 @@ message SharedConfigMessage { USER_PROFILE = 1; CONTACTS = 2; CONVO_INFO_VOLATILE = 3; - GROUPS = 4; + USER_GROUPS = 4; } // @required diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index 08881a282..9b00ee6c5 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -918,23 +918,25 @@ public class SignalAttachment: Equatable, Hashable { let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") exportSession.outputURL = exportURL - let publisher = Future { resolver in - exportSession.exportAsynchronously { - let baseFilename = dataSource.sourceFilename - let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") - - guard let dataSource = DataSourcePath.dataSource(with: exportURL, - shouldDeleteOnDeallocation: true) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) - attachment.error = .couldNotConvertToMpeg4 + let publisher = Deferred { + Future { resolver in + exportSession.exportAsynchronously { + let baseFilename = dataSource.sourceFilename + let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") + + guard let dataSource = DataSourcePath.dataSource(with: exportURL, + shouldDeleteOnDeallocation: true) else { + let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) + attachment.error = .couldNotConvertToMpeg4 + resolver(Result.success(attachment)) + return + } + + dataSource.sourceFilename = mp4Filename + + let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) resolver(Result.success(attachment)) - return } - - dataSource.sourceFilename = mp4Filename - - let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) - resolver(Result.success(attachment)) } } .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 3b11e0003..1f4e1c678 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -70,7 +70,7 @@ extension MessageReceiver { // Create the group let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false) let thread: SessionThread = try SessionThread - .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup) + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup) .with(shouldBeVisible: true) .saved(db) let closedGroup: ClosedGroup = try ClosedGroup( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 467a41930..6e733d227 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -168,7 +168,7 @@ extension MessageReceiver { // past two weeks) if isInitialSync { let existingClosedGroupsIds: [String] = (try? SessionThread - .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyClosedGroup) + .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyGroup) .fetchAll(db)) .defaulting(to: []) .map { $0.id } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index c69eed681..d0f9e531f 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -393,7 +393,7 @@ extension MessageReceiver { ).save(db) } - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: try GroupMember .filter(GroupMember.Columns.groupId == thread.id) .fetchAll(db) @@ -405,7 +405,7 @@ extension MessageReceiver { ).save(db) } - case .openGroup: + case .community: try RecipientState( interactionId: interactionId, recipientId: thread.id, // For open groups this will always be the thread id diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index a8b71888b..e94804030 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -40,7 +40,7 @@ extension MessageSender { do { // Create the relevant objects in the database thread = try SessionThread - .fetchOrCreate(db, id: groupPublicKey, variant: .legacyClosedGroup) + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup) try ClosedGroup( threadId: groupPublicKey, name: name, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d1da2bc22..dfde99552 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -284,14 +284,14 @@ public enum MessageReceiver { // Note: We don't want to create a thread for an open group if it doesn't exist if (try? SessionThread.exists(db, id: openGroupId)) != true { return nil } - return (openGroupId, .openGroup) + return (openGroupId, .community) } if let groupPublicKey: String = message.groupPublicKey { // Note: We don't want to create a thread for a closed group if it doesn't exist if (try? SessionThread.exists(db, id: groupPublicKey)) != true { return nil } - return (groupPublicKey, .legacyClosedGroup) + return (groupPublicKey, .legacyGroup) } // Extract the 'syncTarget' value if there is one diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index b3db30c7e..0502a5d89 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -68,6 +68,7 @@ extension MessageSender { } public static func performUploadsIfNeeded( + queue: DispatchQueue, preparedSendData: PreparedSendData ) -> AnyPublisher { // We need an interactionId in order for a message to have uploads @@ -95,7 +96,7 @@ extension MessageSender { }() return Storage.shared - .readPublisherFlatMap { db -> AnyPublisher<(attachments: [Attachment], openGroup: OpenGroup?), Error> in + .readPublisherFlatMap(receiveOn: queue) { db -> AnyPublisher<(attachments: [Attachment], openGroup: OpenGroup?), Error> in let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment .stateInfo(interactionId: interactionId, state: .uploading) .fetchAll(db)) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 754ecb2e6..7c03c95f3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -236,8 +236,9 @@ public final class MessageSender { return PreparedSendData() } - // Attach the user's profile if needed - if var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { + // 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 + if !isSelfSend, !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { @@ -597,8 +598,8 @@ public final class MessageSender { // 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 call `MessageSender.performUploadsIfNeeded(preparedSendData:)` - // before calling this function + // If you see this error then you need to call + // `MessageSender.performUploadsIfNeeded(queue:preparedSendData:)` before calling this function switch preparedSendData.message { case let visibleMessage as VisibleMessage: guard visibleMessage.attachmentIds.count == preparedSendData.totalAttachmentsUploaded else { @@ -674,7 +675,7 @@ public final class MessageSender { }() return dependencies.storage - .writePublisher { db -> Void in + .writePublisher(receiveOn: DispatchQueue.global(qos: .default)) { db -> Void in try MessageSender.handleSuccessfulMessageSend( db, message: updatedMessage, @@ -701,20 +702,22 @@ public final class MessageSender { .eraseToAnyPublisher() } - return Future { resolver in - NotifyPushServerJob.run( - job, - queue: DispatchQueue.global(qos: .default), - success: { _, _ in resolver(Result.success(true)) }, - failure: { _, _, _ in - // Always fulfill because the notify PN server job isn't critical. - resolver(Result.success(true)) - }, - deferred: { _ in - // Always fulfill because the notify PN server job isn't critical. - resolver(Result.success(true)) - } - ) + return Deferred { + Future { resolver in + NotifyPushServerJob.run( + job, + queue: DispatchQueue.global(qos: .default), + success: { _, _ in resolver(Result.success(true)) }, + failure: { _, _, _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(true)) + }, + deferred: { _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(true)) + } + ) + } } .eraseToAnyPublisher() } @@ -762,7 +765,7 @@ public final class MessageSender { // Send the result return dependencies.storage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .default)) { db in OpenGroupAPI .send( db, @@ -781,7 +784,7 @@ public final class MessageSender { let updatedMessage: Message = message updatedMessage.openGroupServerMessageId = UInt64(responseData.id) - return dependencies.storage.writePublisher { db in + return dependencies.storage.writePublisher(receiveOn: DispatchQueue.global(qos: .default)) { db in // The `posted` value is in seconds but we sent it in ms so need that for de-duping try MessageSender.handleSuccessfulMessageSend( db, @@ -831,7 +834,7 @@ public final class MessageSender { // Send the result return dependencies.storage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.global(qos: .default)) { db in return OpenGroupAPI .send( db, @@ -846,7 +849,7 @@ public final class MessageSender { let updatedMessage: Message = message updatedMessage.openGroupServerMessageId = UInt64(responseData.id) - return dependencies.storage.writePublisher { db in + return dependencies.storage.writePublisher(receiveOn: DispatchQueue.global(qos: .default)) { db in // The `posted` value is in seconds but we sent it in ms so need that for de-duping try MessageSender.handleSuccessfulMessageSend( db, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 735b96503..4ad5a9396 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -59,7 +59,7 @@ public enum PushNotificationAPI { // Unsubscribe from all closed groups (including ones the user is no longer a member of, // just in case) Storage.shared - .readPublisher { db -> (String, Set) in + .readPublisher(receiveOn: DispatchQueue.global(qos: .background)) { db -> (String, Set) in ( getUserHexEncodedPublicKey(db), try ClosedGroup diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 1878f6a60..c84258b4a 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -82,15 +82,12 @@ public final class ClosedGroupPoller: Poller { for publicKey: String ) -> AnyPublisher { return SnodeAPI.getSwarm(for: publicKey) - .flatMap { swarm -> AnyPublisher in + .tryMap { swarm -> Snode in guard let snode: Snode = swarm.randomElement() else { - return Fail(error: OnionRequestAPIError.insufficientSnodes) - .eraseToAnyPublisher() + throw OnionRequestAPIError.insufficientSnodes } - return Just(snode) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return snode } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index d420e709f..e4b07e678 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -9,7 +9,7 @@ import SessionUtilitiesKit public final class CurrentUserPoller: Poller { public static var namespaces: [SnodeAPI.Namespace] = [ - .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configGroups + .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups ] private var targetSnode: Atomic = Atomic(nil) @@ -89,11 +89,8 @@ public final class CurrentUserPoller: Poller { // If we haven't retrieved a target snode at this point then either the cache // is empty or we have used all of the snodes and need to start from scratch return SnodeAPI.getSwarm(for: publicKey) - .flatMap { [weak self] _ -> AnyPublisher in - guard let strongSelf = self else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { [weak self] _ -> AnyPublisher in + guard let strongSelf = self else { throw SnodeAPIError.generic } self?.targetSnode.mutate { $0 = nil } self?.usedSnodes.mutate { $0.removeAll() } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 557642d1d..cd02aa5df 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -91,7 +91,7 @@ extension OpenGroupAPI { let server: String = self.server return dependencies.storage - .readPublisherFlatMap { db -> AnyPublisher<(Int64, PollResponse), Error> in + .readPublisherFlatMap(receiveOn: Threading.pollerQueue) { db -> AnyPublisher<(Int64, PollResponse), Error> in let failureCount: Int64 = (try? OpenGroup .select(max(OpenGroup.Columns.pollFailureCount)) .asRequest(of: Int64.self) @@ -225,7 +225,7 @@ extension OpenGroupAPI { } return dependencies.storage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: OpenGroupAPI.workQueue) { db in OpenGroupAPI.capabilities( db, server: server, diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 5bca3274e..230218237 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -200,9 +200,16 @@ public class Poller { poller?.pollerName(for: publicKey) ?? "poller with public key \(publicKey)" ) + let configHashes: [String] = SessionUtil.configHashes(for: publicKey) // Fetch the messages - return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey) + return SnodeAPI + .poll( + namespaces: namespaces, + refreshingConfigHashes: configHashes, + from: snode, + associatedWith: publicKey + ) .flatMap { namespacedResults -> AnyPublisher in guard (calledFromBackgroundPoller && isBackgroundPollValid()) || @@ -322,15 +329,17 @@ public class Poller { return Publishers .MergeMany( jobsToRun.map { job -> AnyPublisher in - Future { resolver in - // Note: In the background we just want jobs to fail silently - MessageReceiveJob.run( - job, - queue: queue, - success: { _, _ in resolver(Result.success(())) }, - failure: { _, _, _ in resolver(Result.success(())) }, - deferred: { _ in resolver(Result.success(())) } - ) + Deferred { + Future { resolver in + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + queue: queue, + success: { _, _ in resolver(Result.success(())) }, + failure: { _, _, _ in resolver(Result.success(())) }, + deferred: { _ in resolver(Result.success(())) } + ) + } } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 97d5957cb..2d40d29db 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -39,9 +39,9 @@ public class TypingIndicators { // Don't send typing indicators in group threads guard - threadVariant != .legacyClosedGroup && - threadVariant != .closedGroup && - threadVariant != .openGroup + threadVariant != .legacyGroup && + threadVariant != .group && + threadVariant != .community else { return nil } self.threadId = threadId diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index d603f17a2..bbf555030 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -34,7 +34,7 @@ public extension MentionInfo { let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) /// **Note:** The `\(MentionInfo.profileKey).*` value **MUST** be first - let limitSQL: SQL? = (threadVariant == .openGroup ? SQL("LIMIT 20") : nil) + let limitSQL: SQL? = (threadVariant == .community ? SQL("LIMIT 20") : nil) let request: SQLRequest = { guard let pattern: FTS5Pattern = pattern else { @@ -57,7 +57,7 @@ public extension MentionInfo { WHERE ( \(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'")) ) ) @@ -83,7 +83,7 @@ public extension MentionInfo { JOIN \(Profile.self) ON ( \(Profile.self).rowid = \(profileFullTextSearch).rowid AND \(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'")) ) ) diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 129553a42..610a8052b 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -304,9 +304,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, } }() let isGroupThread: Bool = ( - self.threadVariant == .openGroup || - self.threadVariant == .legacyClosedGroup || - self.threadVariant == .closedGroup + self.threadVariant == .community || + self.threadVariant == .legacyGroup || + self.threadVariant == .group ) return ViewModel( @@ -741,13 +741,13 @@ public extension MessageViewModel { \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) ) LEFT JOIN \(GroupMember.self) AS \(groupMemberModeratorTableLiteral) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(groupMemberModeratorTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(interaction[.threadId]) AND \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND \(SQL("\(groupMemberModeratorTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.moderator)")) ) LEFT JOIN \(GroupMember.self) AS \(groupMemberAdminTableLiteral) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(groupMemberAdminTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(interaction[.threadId]) AND \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND \(SQL("\(groupMemberAdminTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 4c5610e2a..be9c267d9 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -103,8 +103,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { case .contact: return true - case .legacyClosedGroup, .closedGroup: return currentUserIsClosedGroupMember == true - case .openGroup: return openGroupPermissions?.contains(.write) ?? false + case .legacyGroup, .group: return currentUserIsClosedGroupMember == true + case .community: return openGroupPermissions?.contains(.write) ?? false } } @@ -161,15 +161,15 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var profile: Profile? { switch threadVariant { case .contact: return contactProfile - case .legacyClosedGroup, .closedGroup: + case .legacyGroup, .group: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) - case .openGroup: return nil + case .community: return nil } } public var additionalProfile: Profile? { switch threadVariant { - case .legacyClosedGroup, .closedGroup: return closedGroupProfileFront + case .legacyGroup, .group: return closedGroupProfileFront default: return nil } } @@ -194,8 +194,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var userCount: Int? { switch threadVariant { case .contact: return nil - case .legacyClosedGroup, .closedGroup: return closedGroupUserCount - case .openGroup: return openGroupUserCount + case .legacyGroup, .group: return closedGroupUserCount + case .community: return openGroupUserCount } } @@ -1355,8 +1355,8 @@ public extension SessionThreadViewModel { LEFT JOIN \(OpenGroup.self) ON false WHERE ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyClosedGroup)")) OR - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) ) GROUP BY \(thread[.id]) """ @@ -1435,7 +1435,7 @@ public extension SessionThreadViewModel { ) AS \(groupMemberInfoLiteral) ON false WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) GROUP BY \(thread[.id]) """ diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 9d13ae126..def1a651a 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -34,7 +34,7 @@ public struct ProfileManager { // Before encrypting and submitting we NULL pad the name data to this length. private static let nameDataLength: UInt = 64 public static let maxAvatarDiameter: CGFloat = 640 - internal static let avatarAES256KeyByteLength: Int = 32 + public static let avatarAES256KeyByteLength: Int = 32 private static let avatarNonceLength: Int = 12 private static let avatarTagLength: Int = 16 diff --git a/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift b/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift index 9012b113d..e9aeb70f5 100644 --- a/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift @@ -32,25 +32,22 @@ class ConfigContactsSpec: QuickSpec { error?.deallocate() // Empty contacts shouldn't have an existing contact - var definitelyRealId: [CChar] = "050000000000000000000000000000000000000000000000000000000000000000" - .bytes - .map { CChar(bitPattern: $0) } + var definitelyRealId: String = "050000000000000000000000000000000000000000000000000000000000000000" + var cDefinitelyRealId: [CChar] = definitelyRealId.cArray let contactPtr: UnsafeMutablePointer? = nil - expect(contacts_get(conf, contactPtr, &definitelyRealId)).to(beFalse()) + expect(contacts_get(conf, contactPtr, &cDefinitelyRealId)).to(beFalse()) expect(contacts_size(conf)).to(equal(0)) var contact2: contacts_contact = contacts_contact() - expect(contacts_get_or_construct(conf, &contact2, &definitelyRealId)).to(beTrue()) - expect(contact2.name).to(beNil()) - expect(contact2.nickname).to(beNil()) + expect(contacts_get_or_construct(conf, &contact2, &cDefinitelyRealId)).to(beTrue()) + expect(String(libSessionVal: contact2.name)).to(beEmpty()) + expect(String(libSessionVal: contact2.nickname)).to(beEmpty()) expect(contact2.approved).to(beFalse()) expect(contact2.approved_me).to(beFalse()) expect(contact2.blocked).to(beFalse()) expect(contact2.profile_pic).toNot(beNil()) // Creates an empty instance apparently - expect(contact2.profile_pic.url).to(beNil()) - expect(contact2.profile_pic.key).to(beNil()) - expect(contact2.profile_pic.keylen).to(equal(0)) + expect(String(libSessionVal: contact2.profile_pic.url)).to(beEmpty()) // We don't need to push anything, since this is a default contact expect(config_needs_push(conf)).to(beFalse()) @@ -68,14 +65,8 @@ class ConfigContactsSpec: QuickSpec { toPush?.deallocate() // Update the contact data - let contact2Name: [CChar] = "Joe" - .bytes - .map { CChar(bitPattern: $0) } - let contact2Nickname: [CChar] = "Joey" - .bytes - .map { CChar(bitPattern: $0) } - contact2Name.withUnsafeBufferPointer { contact2.name = $0.baseAddress } - contact2Nickname.withUnsafeBufferPointer { contact2.nickname = $0.baseAddress } + contact2.name = "Joe".toLibSession() + contact2.nickname = "Joey".toLibSession() contact2.approved = true contact2.approved_me = true @@ -85,19 +76,14 @@ class ConfigContactsSpec: QuickSpec { // Ensure the contact details were updated var contact3: contacts_contact = contacts_contact() expect(contacts_get(conf, &contact3, &definitelyRealId)).to(beTrue()) - expect(String(cString: contact3.name)).to(equal("Joe")) - expect(String(cString: contact3.nickname)).to(equal("Joey")) + expect(String(libSessionVal: contact3.name)).to(equal("Joe")) + expect(String(libSessionVal: contact3.nickname)).to(equal("Joey")) expect(contact3.approved).to(beTrue()) expect(contact3.approved_me).to(beTrue()) expect(contact3.profile_pic).toNot(beNil()) // Creates an empty instance apparently - expect(contact3.profile_pic.url).to(beNil()) - expect(contact3.profile_pic.key).to(beNil()) - expect(contact3.profile_pic.keylen).to(equal(0)) + expect(String(libSessionVal: contact3.profile_pic.url)).to(beEmpty()) expect(contact3.blocked).to(beFalse()) - - let contact3SessionId: [CChar] = withUnsafeBytes(of: contact3.session_id) { [UInt8]($0) } - .map { CChar($0) } - expect(contact3SessionId).to(equal(definitelyRealId.nullTerminated())) + expect(String(libSessionVal: contact3.session_id)).to(equal(definitelyRealId)) // Since we've made changes, we should need to push new config to the swarm, *and* should need // to dump the updated state: @@ -144,29 +130,24 @@ class ConfigContactsSpec: QuickSpec { // Ensure the contact details were updated var contact4: contacts_contact = contacts_contact() expect(contacts_get(conf2, &contact4, &definitelyRealId)).to(beTrue()) - expect(String(cString: contact4.name)).to(equal("Joe")) - expect(String(cString: contact4.nickname)).to(equal("Joey")) + expect(String(libSessionVal: contact4.name)).to(equal("Joe")) + expect(String(libSessionVal: contact4.nickname)).to(equal("Joey")) expect(contact4.approved).to(beTrue()) expect(contact4.approved_me).to(beTrue()) expect(contact4.profile_pic).toNot(beNil()) // Creates an empty instance apparently - expect(contact4.profile_pic.url).to(beNil()) - expect(contact4.profile_pic.key).to(beNil()) - expect(contact4.profile_pic.keylen).to(equal(0)) + expect(String(libSessionVal: contact4.profile_pic.url)).to(beEmpty()) expect(contact4.blocked).to(beFalse()) - var anotherId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111111" - .bytes - .map { CChar(bitPattern: $0) } + var anotherId: String = "051111111111111111111111111111111111111111111111111111111111111111" + var cAnotherId: [CChar] = anotherId.cArray var contact5: contacts_contact = contacts_contact() - expect(contacts_get_or_construct(conf2, &contact5, &anotherId)).to(beTrue()) - expect(contact5.name).to(beNil()) - expect(contact5.nickname).to(beNil()) + expect(contacts_get_or_construct(conf2, &contact5, &cAnotherId)).to(beTrue()) + expect(String(libSessionVal: contact5.name)).to(beEmpty()) + expect(String(libSessionVal: contact5.nickname)).to(beEmpty()) expect(contact5.approved).to(beFalse()) expect(contact5.approved_me).to(beFalse()) expect(contact5.profile_pic).toNot(beNil()) // Creates an empty instance apparently - expect(contact5.profile_pic.url).to(beNil()) - expect(contact5.profile_pic.key).to(beNil()) - expect(contact5.profile_pic.keylen).to(equal(0)) + expect(String(libSessionVal: contact5.profile_pic.url)).to(beEmpty()) expect(contact5.blocked).to(beFalse()) // We're not setting any fields, but we should still keep a record of the session id @@ -201,24 +182,16 @@ class ConfigContactsSpec: QuickSpec { var contact6: contacts_contact = contacts_contact() let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) while !contacts_iterator_done(contactIterator, &contact6) { - sessionIds.append( - String(cString: withUnsafeBytes(of: contact6.session_id) { [UInt8]($0) } - .map { CChar($0) } - .nullTerminated() - ) - ) - nicknames.append( - contact6.nickname.map { String(cString: $0) } ?? - "(N/A)" - ) + sessionIds.append(String(libSessionVal: contact6.session_id) ?? "(N/A)") + nicknames.append(String(libSessionVal: contact6.nickname, nullIfEmpty: true) ?? "(N/A)") contacts_iterator_advance(contactIterator) } contacts_iterator_free(contactIterator) // Need to free the iterator expect(sessionIds.count).to(equal(2)) expect(sessionIds.count).to(equal(contacts_size(conf))) - expect(sessionIds.first).to(equal(String(cString: definitelyRealId.nullTerminated()))) - expect(sessionIds.last).to(equal(String(cString: anotherId.nullTerminated()))) + expect(sessionIds.first).to(equal(definitelyRealId)) + expect(sessionIds.last).to(equal(anotherId)) expect(nicknames.first).to(equal("Joey")) expect(nicknames.last).to(equal("(N/A)")) @@ -228,24 +201,15 @@ class ConfigContactsSpec: QuickSpec { contacts_erase(conf, definitelyRealId) // Client 2 adds a new friend: - var thirdId: [CChar] = "052222222222222222222222222222222222222222222222222222222222222222" - .bytes - .map { CChar(bitPattern: $0) } - let nickname7: [CChar] = "Nickname 3" - .bytes - .map { CChar(bitPattern: $0) } - let profileUrl7: [CChar] = "http://example.com/huge.bmp" - .bytes - .map { CChar(bitPattern: $0) } - let profileKey7: [UInt8] = "qwerty".bytes + var thirdId: String = "052222222222222222222222222222222222222222222222222222222222222222" + var cThirdId: [CChar] = thirdId.cArray var contact7: contacts_contact = contacts_contact() - expect(contacts_get_or_construct(conf2, &contact7, &thirdId)).to(beTrue()) - nickname7.withUnsafeBufferPointer { contact7.nickname = $0.baseAddress } + expect(contacts_get_or_construct(conf2, &contact7, &cThirdId)).to(beTrue()) + contact7.nickname = "Nickname 3".toLibSession() contact7.approved = true contact7.approved_me = true - profileUrl7.withUnsafeBufferPointer { contact7.profile_pic.url = $0.baseAddress } - profileKey7.withUnsafeBufferPointer { contact7.profile_pic.key = $0.baseAddress } - contact7.profile_pic.keylen = 6 + contact7.profile_pic.url = "http://example.com/huge.bmp".toLibSession() + contact7.profile_pic.key = "qwerty78901234567890123456789012".data(using: .utf8)!.toLibSession() contacts_set(conf2, &contact7) expect(config_needs_push(conf)).to(beTrue()) @@ -308,23 +272,15 @@ class ConfigContactsSpec: QuickSpec { var contact8: contacts_contact = contacts_contact() let contactIterator2: UnsafeMutablePointer = contacts_iterator_new(conf) while !contacts_iterator_done(contactIterator2, &contact8) { - sessionIds2.append( - String(cString: withUnsafeBytes(of: contact8.session_id) { [UInt8]($0) } - .map { CChar($0) } - .nullTerminated() - ) - ) - nicknames2.append( - contact8.nickname.map { String(cString: $0) } ?? - "(N/A)" - ) + sessionIds2.append(String(libSessionVal: contact8.session_id) ?? "(N/A)") + nicknames2.append(String(libSessionVal: contact8.nickname, nullIfEmpty: true) ?? "(N/A)") contacts_iterator_advance(contactIterator2) } contacts_iterator_free(contactIterator2) // Need to free the iterator expect(sessionIds2.count).to(equal(2)) - expect(sessionIds2.first).to(equal(String(cString: anotherId.nullTerminated()))) - expect(sessionIds2.last).to(equal(String(cString: thirdId.nullTerminated()))) + expect(sessionIds2.first).to(equal(anotherId)) + expect(sessionIds2.last).to(equal(thirdId)) expect(nicknames2.first).to(equal("(N/A)")) expect(nicknames2.last).to(equal("Nickname 3")) } diff --git a/SessionMessagingKitTests/LibSessionUtil/ConfigConvoInfoVolatileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/ConfigConvoInfoVolatileSpec.swift index da437701e..d8f706179 100644 --- a/SessionMessagingKitTests/LibSessionUtil/ConfigConvoInfoVolatileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/ConfigConvoInfoVolatileSpec.swift @@ -60,9 +60,9 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { // The new data doesn't get stored until we call this: convo_info_volatile_set_1to1(conf, &oneToOne2) - var legacyClosed1: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + var legacyGroup1: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() var oneToOne3: convo_info_volatile_1to1 = convo_info_volatile_1to1() - expect(convo_info_volatile_get_legacy_closed(conf, &legacyClosed1, &definitelyRealId)) + expect(convo_info_volatile_get_legacy_group(conf, &legacyGroup1, &definitelyRealId)) .to(beFalse()) expect(convo_info_volatile_get_1to1(conf, &oneToOne3, &definitelyRealId)).to(beTrue()) expect(oneToOne3.last_read).to(equal(nowTimestampMs)) @@ -70,40 +70,34 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { expect(config_needs_push(conf)).to(beTrue()) expect(config_needs_dump(conf)).to(beTrue()) - var openGroupBaseUrl: [CChar] = "http://Example.ORG:5678" - .bytes - .map { CChar(bitPattern: $0) } + var openGroupBaseUrl: [CChar] = "http://Example.ORG:5678".cArray let openGroupBaseUrlResult: [CChar] = ("http://Example.ORG:5678" .lowercased() - .bytes - .map { CChar(bitPattern: $0) } + + .cArray + [CChar](repeating: 0, count: (268 - openGroupBaseUrl.count)) ) - var openGroupRoom: [CChar] = "SudokuRoom" - .bytes - .map { CChar(bitPattern: $0) } + var openGroupRoom: [CChar] = "SudokuRoom".cArray let openGroupRoomResult: [CChar] = ("SudokuRoom" .lowercased() - .bytes - .map { CChar(bitPattern: $0) } + + .cArray + [CChar](repeating: 0, count: (65 - openGroupRoom.count)) ) var openGroupPubkey: [UInt8] = Data(hex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") .bytes - var openGroup1: convo_info_volatile_open = convo_info_volatile_open() - expect(convo_info_volatile_get_or_construct_open(conf, &openGroup1, &openGroupBaseUrl, &openGroupRoom, &openGroupPubkey)).to(beTrue()) - expect(withUnsafeBytes(of: openGroup1.base_url) { [UInt8]($0) } + var community1: convo_info_volatile_community = convo_info_volatile_community() + expect(convo_info_volatile_get_or_construct_community(conf, &community1, &openGroupBaseUrl, &openGroupRoom, &openGroupPubkey)).to(beTrue()) + expect(withUnsafeBytes(of: community1.base_url) { [UInt8]($0) } .map { CChar($0) } ).to(equal(openGroupBaseUrlResult)) - expect(withUnsafeBytes(of: openGroup1.room) { [UInt8]($0) } + expect(withUnsafeBytes(of: community1.room) { [UInt8]($0) } .map { CChar($0) } ).to(equal(openGroupRoomResult)) - expect(withUnsafePointer(to: openGroup1.pubkey) { Data(bytes: $0, count: 32).toHexString() }) + expect(withUnsafePointer(to: community1.pubkey) { Data(bytes: $0, count: 32).toHexString() }) .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) - openGroup1.unread = true + community1.unread = true // The new data doesn't get stored until we call this: - convo_info_volatile_set_open(conf, &openGroup1); + convo_info_volatile_set_community(conf, &community1); var toPush: UnsafeMutablePointer? = nil var toPushLen: Int = 0 @@ -143,17 +137,17 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { ).to(equal(definitelyRealId.nullTerminated())) expect(oneToOne4.unread).to(beFalse()) - var openGroup2: convo_info_volatile_open = convo_info_volatile_open() - expect(convo_info_volatile_get_open(conf2, &openGroup2, &openGroupBaseUrl, &openGroupRoom, &openGroupPubkey)).to(beTrue()) - expect(withUnsafeBytes(of: openGroup2.base_url) { [UInt8]($0) } + var community2: convo_info_volatile_community = convo_info_volatile_community() + expect(convo_info_volatile_get_community(conf2, &community2, &openGroupBaseUrl, &openGroupRoom)).to(beTrue()) + expect(withUnsafeBytes(of: community2.base_url) { [UInt8]($0) } .map { CChar($0) } ).to(equal(openGroupBaseUrlResult)) - expect(withUnsafeBytes(of: openGroup2.room) { [UInt8]($0) } + expect(withUnsafeBytes(of: community2.room) { [UInt8]($0) } .map { CChar($0) } ).to(equal(openGroupRoomResult)) - expect(withUnsafePointer(to: openGroup2.pubkey) { Data(bytes: $0, count: 32).toHexString() }) + expect(withUnsafePointer(to: community2.pubkey) { Data(bytes: $0, count: 32).toHexString() }) .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) - openGroup2.unread = true + community2.unread = true var anotherId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111111" .bytes @@ -165,10 +159,10 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { var thirdId: [CChar] = "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" .bytes .map { CChar(bitPattern: $0) } - var legacyClosed2: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() - expect(convo_info_volatile_get_or_construct_legacy_closed(conf2, &legacyClosed2, &thirdId)).to(beTrue()) - legacyClosed2.last_read = (nowTimestampMs - 50) - convo_info_volatile_set_legacy_closed(conf2, &legacyClosed2) + var legacyGroup2: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + expect(convo_info_volatile_get_or_construct_legacy_group(conf2, &legacyGroup2, &thirdId)).to(beTrue()) + legacyGroup2.last_read = (nowTimestampMs - 50) + convo_info_volatile_set_legacy_group(conf2, &legacyGroup2) expect(config_needs_push(conf2)).to(beTrue()) var toPush2: UnsafeMutablePointer? = nil @@ -190,12 +184,12 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { var seen: [String] = [] expect(convo_info_volatile_size(conf)).to(equal(4)) expect(convo_info_volatile_size_1to1(conf)).to(equal(2)) - expect(convo_info_volatile_size_open(conf)).to(equal(1)) - expect(convo_info_volatile_size_legacy_closed(conf)).to(equal(1)) + expect(convo_info_volatile_size_communities(conf)).to(equal(1)) + expect(convo_info_volatile_size_legacy_groups(conf)).to(equal(1)) var c1: convo_info_volatile_1to1 = convo_info_volatile_1to1() - var c2: convo_info_volatile_open = convo_info_volatile_open() - var c3: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() + var c2: convo_info_volatile_community = convo_info_volatile_community() + var c3: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() let it: OpaquePointer = convo_info_volatile_iterator_new(targetConf) while !convo_info_volatile_iterator_done(it) { @@ -206,7 +200,7 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { ) seen.append("1-to-1: \(sessionId)") } - else if convo_info_volatile_it_is_open(it, &c2) { + else if convo_info_volatile_it_is_community(it, &c2) { let baseUrl: String = String(cString: withUnsafeBytes(of: c2.base_url) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() @@ -218,7 +212,7 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { seen.append("og: \(baseUrl)/r/\(room)") } - else if convo_info_volatile_it_is_legacy_closed(it, &c3) { + else if convo_info_volatile_it_is_legacy_group(it, &c3) { let groupId: String = String(cString: withUnsafeBytes(of: c3.group_id) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() @@ -271,11 +265,11 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { ])) var seen2: [String] = [] - var c2: convo_info_volatile_open = convo_info_volatile_open() - let it2: OpaquePointer = convo_info_volatile_iterator_new_open(conf) + var c2: convo_info_volatile_community = convo_info_volatile_community() + let it2: OpaquePointer = convo_info_volatile_iterator_new_communities(conf) while !convo_info_volatile_iterator_done(it2) { - expect(convo_info_volatile_it_is_open(it2, &c2)).to(beTrue()) + expect(convo_info_volatile_it_is_community(it2, &c2)).to(beTrue()) let baseUrl: String = String(cString: withUnsafeBytes(of: c2.base_url) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() @@ -291,11 +285,11 @@ class ConfigConvoInfoVolatileSpec: QuickSpec { ])) var seen3: [String] = [] - var c3: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed() - let it3: OpaquePointer = convo_info_volatile_iterator_new_legacy_closed(conf) + var c3: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + let it3: OpaquePointer = convo_info_volatile_iterator_new_legacy_groups(conf) while !convo_info_volatile_iterator_done(it3) { - expect(convo_info_volatile_it_is_legacy_closed(it3, &c3)).to(beTrue()) + expect(convo_info_volatile_it_is_legacy_group(it3, &c3)).to(beTrue()) let groupId: String = String(cString: withUnsafeBytes(of: c3.group_id) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() diff --git a/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift index ef9324420..b36b5cbba 100644 --- a/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift @@ -4,6 +4,7 @@ import Foundation import Sodium import SessionUtil import SessionUtilitiesKit +import SessionMessagingKit import Quick import Nimble @@ -68,25 +69,14 @@ class ConfigUserProfileSpec: QuickSpec { // This should also be unset: let pic: user_profile_pic = user_profile_get_pic(conf) - expect(pic.url).to(beNil()) - expect(pic.key).to(beNil()) - expect(pic.keylen).to(equal(0)) + expect(String(libSessionVal: pic.url)).to(beEmpty()) // Now let's go set a profile name and picture: expect(user_profile_set_name(conf, "Kallie")).to(equal(0)) - let profileUrl: [CChar] = "http://example.org/omg-pic-123.bmp" - .bytes - .map { CChar(bitPattern: $0) } - let profileKey: [UInt8] = "secretNOTSECRET".bytes - let p: user_profile_pic = profileUrl.withUnsafeBufferPointer { profileUrlPtr in - profileKey.withUnsafeBufferPointer { profileKeyPtr in - user_profile_pic( - url: profileUrlPtr.baseAddress, - key: profileKeyPtr.baseAddress, - keylen: 6 - ) - } - } + let p: user_profile_pic = user_profile_pic( + url: "http://example.org/omg-pic-123.bmp".toLibSession(), + key: "secret78901234567890123456789012".data(using: .utf8)!.toLibSession() + ) expect(user_profile_set_pic(conf, p)).to(equal(0)) // Retrieve them just to make sure they set properly: @@ -95,11 +85,9 @@ class ConfigUserProfileSpec: QuickSpec { expect(String(cString: namePtr2!)).to(equal("Kallie")) let pic2: user_profile_pic = user_profile_get_pic(conf); - expect(pic2.url).toNot(beNil()) - expect(pic2.key).toNot(beNil()) - expect(pic2.keylen).to(equal(6)) - expect(String(cString: pic2.url!)).to(equal("http://example.org/omg-pic-123.bmp")) - expect(String(pointer: pic2.key, length: pic2.keylen)).to(equal("secret")) + expect(String(libSessionVal: pic2.url)).to(equal("http://example.org/omg-pic-123.bmp")) + expect(Data(libSessionVal: pic2.key, count: ProfileManager.avatarAES256KeyByteLength)) + .to(equal("secret78901234567890123456789012".data(using: .utf8))) // Since we've made changes, we should need to push new config to the swarm, *and* should need // to dump the updated state: @@ -125,7 +113,7 @@ class ConfigUserProfileSpec: QuickSpec { 1:& d 1:n 6:Kallie 1:p 34:http://example.org/omg-pic-123.bmp - 1:q 6:secret + 1:q 32:secret78901234567890123456789012 e 1:< l l i0e 32: @@ -146,12 +134,13 @@ class ConfigUserProfileSpec: QuickSpec { ] .flatMap { $0 } let expPush1Encrypted: [UInt8] = Data(hex: [ - "a2952190dcb9797bc48e48f6dc7b3254d004bde9091cfc9ec3433cbc5939a3726deb04f58a546d7d79e6f8", - "0ea185d43bf93278398556304998ae882304075c77f15c67f9914c4d10005a661f29ff7a79e0a9de7f2172", - "5ba3b5a6c19eaa3797671b8fa4008d62e9af2744629cbb46664c4d8048e2867f66ed9254120371bdb24e95", - "b2d92341fa3b1f695046113a768ceb7522269f937ead5591bfa8a5eeee3010474002f2db9de043f0f0d1cf", - "b1066a03e7b5d6cfb70a8f84a20cd2df5a510cd3d175708015a52dd4a105886d916db0005dbea5706e5a5d", - "c37ffd0a0ca2824b524da2e2ad181a48bb38e21ed9abe136014a4ee1e472cb2f53102db2a46afa9d68" + "877c8e0f5d33f5fffa5a4e162785a9a89918e95de1c4b925201f1f5c29d9ee4f8c36e2b278fce1e6", + "b9d999689dd86ff8e79e0a04004fa54d24da89bc2604cb1df8c1356da8f14710543ecec44f2d57fc", + "56ea8b7e73d119c69d755f4d513d5d069f02396b8ec0cbed894169836f57ca4b782ce705895c593b", + "4230d50c175d44a08045388d3f4160bacb617b9ae8de3ebc8d9024245cd09ce102627cab2acf1b91", + "26159211359606611ca5814de320d1a7099a65c99b0eebbefb92a115f5efa6b9132809300ac010c6", + "857cfbd62af71b0fa97eccec75cb95e67edf40b35fdb9cad125a6976693ab085c6bba96a2e51826e", + "81e16b9ec1232af5680f2ced55310486" ].joined()).bytes expect(String(pointer: toPush2, length: toPush2Len, encoding: .ascii)) @@ -259,19 +248,10 @@ class ConfigUserProfileSpec: QuickSpec { user_profile_set_name(conf2, "Raz") // And, on conf2, we're also going to change the profile pic: - let profile2Url: [CChar] = "http://new.example.com/pic" - .bytes - .map { CChar(bitPattern: $0) } - let profile2Key: [UInt8] = "qwert\0yuio".bytes - let p2: user_profile_pic = profile2Url.withUnsafeBufferPointer { profile2UrlPtr in - profile2Key.withUnsafeBufferPointer { profile2KeyPtr in - user_profile_pic( - url: profile2UrlPtr.baseAddress, - key: profile2KeyPtr.baseAddress, - keylen: 10 - ) - } - } + let p2: user_profile_pic = user_profile_pic( + url: "http://new.example.com/pic".toLibSession(), + key: "qwert\0yuio1234567890123456789012".data(using: .utf8)!.toLibSession() + ) user_profile_set_pic(conf2, p2) // Both have changes, so push need a push @@ -336,14 +316,16 @@ class ConfigUserProfileSpec: QuickSpec { // Since only one of them set a profile pic there should be no conflict there: let pic3: user_profile_pic = user_profile_get_pic(conf) expect(pic3.url).toNot(beNil()) - expect(String(cString: pic3.url!)).to(equal("http://new.example.com/pic")) + expect(String(libSessionVal: pic3.url)).to(equal("http://new.example.com/pic")) expect(pic3.key).toNot(beNil()) - expect(String(pointer: pic3.key, length: pic3.keylen)).to(equal("qwert\0yuio")) + expect(Data(libSessionVal: pic3.key, count: 32).toHexString()) + .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) let pic4: user_profile_pic = user_profile_get_pic(conf2) expect(pic4.url).toNot(beNil()) - expect(String(cString: pic4.url!)).to(equal("http://new.example.com/pic")) + expect(String(libSessionVal: pic4.url)).to(equal("http://new.example.com/pic")) expect(pic4.key).toNot(beNil()) - expect(String(pointer: pic4.key, length: pic4.keylen)).to(equal("qwert\0yuio")) + expect(Data(libSessionVal: pic4.key, count: 32).toHexString()) + .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) config_confirm_pushed(conf, seqno5) config_confirm_pushed(conf2, seqno6) diff --git a/SessionMessagingKitTests/LibSessionUtil/Utilities/TypeConversionUtilitiesSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Utilities/TypeConversionUtilitiesSpec.swift new file mode 100644 index 000000000..79ec212da --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Utilities/TypeConversionUtilitiesSpec.swift @@ -0,0 +1,196 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class TypeConversionUtilitiesSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + // MARK: - String + + describe("a String") { + it("can convert to a cArray") { + expect("Test123".cArray).to(equal([84, 101, 115, 116, 49, 50, 51])) + } + + context("when initialised with a pointer and length") { + it("returns null when given a null pointer") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: nil, length: 5) + } + + expect(result).to(beNil()) + } + + it("returns a truncated string when given an incorrect length") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: UnsafeRawPointer(ptr.baseAddress), length: 2) + } + + expect(result).to(equal("Te")) + } + + it("returns a string when valid") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: UnsafeRawPointer(ptr.baseAddress), length: 5) + } + + expect(result).to(equal("Test\0")) + } + } + + context("when initialised with a libSession value") { + it("returns a string when valid and null terminated") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 115, 116, 0) + let result = String(libSessionVal: value, nullTerminated: true) + + expect(result).to(equal("Test")) + } + + it("returns a string when valid and not null terminated") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 0, 115, 116) + let result = String(libSessionVal: value, nullTerminated: false) + + expect(result).to(equal("Te\0st")) + } + + it("returns an empty string when null and not set to return null") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value, nullIfEmpty: false) + + expect(result).to(equal("")) + } + + it("returns null when specified and empty") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value, nullIfEmpty: true) + + expect(result).to(beNil()) + } + + it("defaults the null terminated flag to true") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 0, 0, 0) + let result = String(libSessionVal: value) + + expect(result).to(equal("Te")) + } + + it("defaults the null if empty flag to false") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value) + + expect(result).to(equal("")) + } + } + + it("can convert to a libSession value") { + let result: (CChar, CChar, CChar, CChar, CChar) = "Test".toLibSession() + expect(result.0).to(equal(84)) + expect(result.1).to(equal(101)) + expect(result.2).to(equal(115)) + expect(result.3).to(equal(116)) + expect(result.4).to(equal(0)) + } + + context("when optional") { + context("returns null when null") { + let value: String? = nil + let result: (CChar, CChar, CChar, CChar, CChar)? = value?.toLibSession() + + expect(result).to(beNil()) + } + + context("returns a libSession value when not null") { + let value: String? = "Test" + let result: (CChar, CChar, CChar, CChar, CChar)? = value?.toLibSession() + + expect(result?.0).to(equal(84)) + expect(result?.1).to(equal(101)) + expect(result?.2).to(equal(115)) + expect(result?.3).to(equal(116)) + expect(result?.4).to(equal(0)) + } + } + } + + // MARK: - Data + + describe("Data") { + it("can convert to a cArray") { + expect(Data([1, 2, 3]).cArray).to(equal([1, 2, 3])) + } + + context("when initialised with a libSession value") { + it("returns truncated data when given the wrong length") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (1, 2, 3, 4, 5) + let result = Data(libSessionVal: value, count: 2) + + expect(result).to(equal(Data([1, 2]))) + } + + it("returns data when valid") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (1, 2, 3, 4, 5) + let result = Data(libSessionVal: value, count: 5) + + expect(result).to(equal(Data([1, 2, 3, 4, 5]))) + } + } + + it("can convert to a libSession value") { + let result: (Int8, Int8, Int8, Int8, Int8) = Data([1, 2, 3, 4, 5]).toLibSession() + expect(result.0).to(equal(1)) + expect(result.1).to(equal(2)) + expect(result.2).to(equal(3)) + expect(result.3).to(equal(4)) + expect(result.4).to(equal(5)) + } + + context("when optional") { + context("returns null when null") { + let value: Data? = nil + let result: (Int8, Int8, Int8, Int8, Int8)? = value?.toLibSession() + + expect(result).to(beNil()) + } + + context("returns a libSession value when not null") { + let value: Data? = Data([1, 2, 3, 4, 5]) + let result: (Int8, Int8, Int8, Int8, Int8)? = value?.toLibSession() + + expect(result?.0).to(equal(1)) + expect(result?.1).to(equal(2)) + expect(result?.2).to(equal(3)) + expect(result?.3).to(equal(4)) + expect(result?.4).to(equal(5)) + } + } + } + + // MARK: - Array + + describe("an Array") { + context("when adding a null terminated character") { + it("adds a null termination character when not present") { + let value: [CChar] = [1, 2, 3, 4, 5] + + expect(value.nullTerminated()).to(equal([1, 2, 3, 4, 5, 0])) + } + + it("adds nothing when already present") { + let value: [CChar] = [1, 2, 3, 4, 0] + + expect(value.nullTerminated()).to(equal([1, 2, 3, 4, 0])) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 384e46edc..98c0c6f5b 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -186,7 +186,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the correct request") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -221,7 +221,7 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -250,7 +250,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -279,7 +279,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -308,7 +308,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -340,7 +340,7 @@ class OpenGroupAPISpec: QuickSpec { it("does not call the inbox and outbox endpoints") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -439,7 +439,7 @@ class OpenGroupAPISpec: QuickSpec { it("includes the inbox and outbox endpoints") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -466,7 +466,7 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent inbox messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -495,7 +495,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -519,7 +519,7 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent outbox messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -548,7 +548,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -609,7 +609,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -639,7 +639,7 @@ class OpenGroupAPISpec: QuickSpec { it("errors when no data is returned") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -668,7 +668,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -697,7 +697,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -726,7 +726,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -787,7 +787,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.poll( db, server: "testserver", @@ -825,7 +825,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.capabilities( db, server: "testserver", @@ -895,7 +895,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.rooms( db, server: "testserver", @@ -986,7 +986,7 @@ class OpenGroupAPISpec: QuickSpec { var response: OpenGroupAPI.CapabilitiesAndRoomResponse? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.capabilitiesAndRoom( db, for: "testRoom", @@ -1041,7 +1041,7 @@ class OpenGroupAPISpec: QuickSpec { var response: OpenGroupAPI.CapabilitiesAndRoomResponse? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .capabilitiesAndRoom( db, @@ -1113,7 +1113,7 @@ class OpenGroupAPISpec: QuickSpec { var response: OpenGroupAPI.CapabilitiesAndRoomResponse? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .capabilitiesAndRoom( db, @@ -1202,7 +1202,7 @@ class OpenGroupAPISpec: QuickSpec { var response: OpenGroupAPI.CapabilitiesAndRoomResponse? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI.capabilitiesAndRoom( db, for: "testRoom", @@ -1261,7 +1261,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1306,7 +1306,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1346,7 +1346,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1381,7 +1381,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1414,7 +1414,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1454,7 +1454,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1494,7 +1494,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1529,7 +1529,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1570,7 +1570,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -1623,7 +1623,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .message( db, @@ -1675,7 +1675,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1716,7 +1716,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1755,7 +1755,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1789,7 +1789,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1821,7 +1821,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1860,7 +1860,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1899,7 +1899,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1933,7 +1933,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -1973,7 +1973,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageUpdate( db, @@ -2010,7 +2010,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: Data?)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messageDelete( db, @@ -2054,7 +2054,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .messagesDeleteAll( db, @@ -2094,7 +2094,7 @@ class OpenGroupAPISpec: QuickSpec { var response: ResponseInfoType? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .pinMessage( db, @@ -2132,7 +2132,7 @@ class OpenGroupAPISpec: QuickSpec { var response: ResponseInfoType? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .unpinMessage( db, @@ -2170,7 +2170,7 @@ class OpenGroupAPISpec: QuickSpec { var response: ResponseInfoType? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .unpinAll( db, @@ -2209,7 +2209,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .uploadFile( db, @@ -2245,7 +2245,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .uploadFile( db, @@ -2281,7 +2281,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .uploadFile( db, @@ -2319,7 +2319,7 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .downloadFile( db, @@ -2376,7 +2376,7 @@ class OpenGroupAPISpec: QuickSpec { var response: (info: ResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .send( db, @@ -2425,7 +2425,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userBan( db, @@ -2455,7 +2455,7 @@ class OpenGroupAPISpec: QuickSpec { it("does a global ban if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userBan( db, @@ -2487,7 +2487,7 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userBan( db, @@ -2534,7 +2534,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userUnban( db, @@ -2563,7 +2563,7 @@ class OpenGroupAPISpec: QuickSpec { it("does a global ban if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userUnban( db, @@ -2594,7 +2594,7 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userUnban( db, @@ -2640,7 +2640,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userModeratorUpdate( db, @@ -2672,7 +2672,7 @@ class OpenGroupAPISpec: QuickSpec { it("does a global update if no room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userModeratorUpdate( db, @@ -2706,7 +2706,7 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific updates if room tokens are provided") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userModeratorUpdate( db, @@ -2740,7 +2740,7 @@ class OpenGroupAPISpec: QuickSpec { it("fails if neither moderator or admin are set") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userModeratorUpdate( db, @@ -2804,7 +2804,7 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userBanAndDeleteAllMessages( db, @@ -2833,7 +2833,7 @@ class OpenGroupAPISpec: QuickSpec { it("bans the user from the specified room rather than globally") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .userBanAndDeleteAllMessages( db, @@ -2890,7 +2890,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -2917,7 +2917,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -2944,7 +2944,7 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -2975,7 +2975,7 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -3011,7 +3011,7 @@ class OpenGroupAPISpec: QuickSpec { mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -3044,7 +3044,7 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -3081,7 +3081,7 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, @@ -3108,7 +3108,7 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .readPublisherFlatMap { db in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { db in OpenGroupAPI .rooms( db, diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 07e5e81d8..cb5fa9b3a 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -153,7 +153,7 @@ class OpenGroupManagerSpec: QuickSpec { testGroupThread = SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - variant: .openGroup + variant: .community ) testOpenGroup = OpenGroup( server: "testServer", @@ -681,7 +681,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), - variant: .openGroup, + variant: .community, creationDateTimestamp: 0, shouldBeVisible: true, isPinned: false, @@ -713,7 +713,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), - variant: .openGroup, + variant: .community, creationDateTimestamp: 0, shouldBeVisible: true, isPinned: false, @@ -815,7 +815,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in openGroupManager .add( db, @@ -845,7 +845,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in openGroupManager .add( db, @@ -881,7 +881,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in openGroupManager .add( db, @@ -936,7 +936,7 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in openGroupManager .add( db, @@ -3325,8 +3325,8 @@ class OpenGroupManagerSpec: QuickSpec { let publisher = Future<[OpenGroupAPI.Room], Error> { resolver in resolver(Result.success([uniqueRoomInstance])) } - .shareReplay(1) - .eraseToAnyPublisher() + .shareReplay(1) + .eraseToAnyPublisher() mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher) let publisher2 = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) @@ -3587,7 +3587,7 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3606,7 +3606,7 @@ class OpenGroupManagerSpec: QuickSpec { it("does not save the fetched image to storage") { var didComplete: Bool = false mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3637,7 +3637,7 @@ class OpenGroupManagerSpec: QuickSpec { it("does not update the image update timestamp") { var didComplete: Bool = false mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3679,7 +3679,7 @@ class OpenGroupManagerSpec: QuickSpec { dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) let publisher = mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3705,7 +3705,7 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3725,7 +3725,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3757,7 +3757,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3805,7 +3805,7 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, @@ -3835,7 +3835,7 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in + .readPublisherFlatMap(receiveOn: DispatchQueue.main) { (db: Database) -> AnyPublisher in OpenGroupManager .roomImage( db, diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 63a6916a1..9c625f9b6 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -26,7 +26,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) var notificationTitle: String = senderName - if thread.variant == .legacyClosedGroup || thread.variant == .closedGroup || thread.variant == .openGroup { + if thread.variant == .legacyGroup || thread.variant == .group || thread.variant == .community { if thread.onlyNotifyForMentions && !interaction.hasMention { // Ignore PNs if the group is set to only notify for mentions return @@ -85,11 +85,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // Add request (try to group notifications for interactions from open groups) let identifier: String = interaction.notificationIdentifier( - shouldGroupMessagesForThread: (thread.variant == .openGroup) + shouldGroupMessagesForThread: (thread.variant == .community) ) var trigger: UNNotificationTrigger? - if thread.variant == .openGroup { + if thread.variant == .community { trigger = UNTimeIntervalNotificationTrigger( timeInterval: Notifications.delayForGroupedNotifications, repeats: false @@ -128,9 +128,9 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard - thread.variant != .legacyClosedGroup && - thread.variant != .closedGroup && - thread.variant != .openGroup + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community else { return } guard interaction.variant == .infoCall, @@ -186,9 +186,9 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard - thread.variant != .legacyClosedGroup && - thread.variant != .closedGroup && - thread.variant != .openGroup + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community else { return } guard !isMessageRequest else { return } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 7dd880554..fb5ef4037 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -83,7 +83,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .asRequest(of: SessionThread.Variant.self) .fetchOne(db) } - let isOpenGroup: Bool = (maybeVariant == .openGroup) + let isOpenGroup: Bool = (maybeVariant == .community) switch processedMessage.messageInfo.message { case let visibleMessage as VisibleMessage: diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index c5eb6ce7e..87d46901b 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -432,163 +432,165 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } Logger.debug("matched utiType: \(srcUtiType)") - return Future { resolver in - let loadCompletion: NSItemProvider.CompletionHandler = { [weak self] value, error in - guard self != nil else { return } - if let error: Error = error { - resolver(Result.failure(error)) - return - } - - guard let value = value else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "missing item provider")) - ) - return - } - - Logger.info("value type: \(type(of: value))") - - switch value { - case let data as Data: - let customFileName = "Contact.vcf" - - let customFileExtension = MIMETypeUtil.fileExtension(forUTIType: srcUtiType) - guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: customFileExtension) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - let fileUrl = URL(fileURLWithPath: tempFilePath) - + return Deferred { + Future { resolver in + let loadCompletion: NSItemProvider.CompletionHandler = { [weak self] value, error in + guard self != nil else { return } + if let error: Error = error { + resolver(Result.failure(error)) + return + } + + guard let value = value else { resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - utiType: srcUtiType, - customFileName: customFileName, - isConvertibleToContactShare: false - ) - ) + Result.failure(ShareViewControllerError.assertionError(description: "missing item provider")) ) - - case let string as String: - Logger.debug("string provider: \(string)") - guard let data = string.filterStringForDisplay().data(using: String.Encoding.utf8) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: "txt") else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - - let fileUrl = URL(fileURLWithPath: tempFilePath) - - let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) - - if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) { + return + } + + Logger.info("value type: \(type(of: value))") + + switch value { + case let data as Data: + let customFileName = "Contact.vcf" + + let customFileExtension = MIMETypeUtil.fileExtension(forUTIType: srcUtiType) + guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: customFileExtension) else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + let fileUrl = URL(fileURLWithPath: tempFilePath) + resolver( Result.success( LoadedItem( itemProvider: itemProvider, itemUrl: fileUrl, utiType: srcUtiType, - isConvertibleToTextMessage: isConvertibleToTextMessage + customFileName: customFileName, + isConvertibleToContactShare: false ) ) ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - utiType: kUTTypeText as String, - isConvertibleToTextMessage: isConvertibleToTextMessage + + case let string as String: + Logger.debug("string provider: \(string)") + guard let data = string.filterStringForDisplay().data(using: String.Encoding.utf8) else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: "txt") else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + + let fileUrl = URL(fileURLWithPath: tempFilePath) + + let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) + + if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: fileUrl, + utiType: srcUtiType, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) ) ) - ) - } - - case let url as URL: - // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. - let isConvertibleToTextMessage = ( - itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) && - !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) - ) - - if isConvertibleToTextMessage { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - utiType: kUTTypeURL as String, - isConvertibleToTextMessage: isConvertibleToTextMessage + } + else { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: fileUrl, + utiType: kUTTypeText as String, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) ) ) + } + + case let url as URL: + // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. + let isConvertibleToTextMessage = ( + itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) && + !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - utiType: srcUtiType, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) - ) - } - - case let image as UIImage: - if let data = image.pngData() { - let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") - do { - let url = NSURL.fileURL(withPath: tempFilePath) - try data.write(to: url) - + + if isConvertibleToTextMessage { resolver( Result.success( LoadedItem( itemProvider: itemProvider, itemUrl: url, - utiType: srcUtiType + utiType: kUTTypeURL as String, + isConvertibleToTextMessage: isConvertibleToTextMessage ) ) ) } - catch { + else { resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: url, + utiType: srcUtiType, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) + ) ) } - } - else { + + case let image as UIImage: + if let data = image.pngData() { + let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") + do { + let url = NSURL.fileURL(withPath: tempFilePath) + try data.write(to: url) + + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: url, + utiType: srcUtiType + ) + ) + ) + } + catch { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) + ) + } + } + else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) + ) + } + + default: + // It's unavoidable that we may sometimes receives data types that we + // don't know how to handle. resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) + Result.failure(ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")) ) - } - - default: - // It's unavoidable that we may sometimes receives data types that we - // don't know how to handle. - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")) - ) + } } + + itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion) } - - itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion) } .eraseToAnyPublisher() } @@ -652,11 +654,9 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { private func buildAttachments() -> AnyPublisher<[SignalAttachment], Error> { return selectItemProviders() - .flatMap { [weak self] itemProviders -> AnyPublisher<[SignalAttachment], Error> in + .tryFlatMap { [weak self] itemProviders -> AnyPublisher<[SignalAttachment], Error> in guard let strongSelf = self else { - let error = ShareViewControllerError.assertionError(description: "expired") - return Fail(error: error) - .eraseToAnyPublisher() + throw ShareViewControllerError.assertionError(description: "expired") } var loadPublishers = [AnyPublisher]() @@ -676,15 +676,12 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { .collect() .eraseToAnyPublisher() } - .flatMap { signalAttachments -> AnyPublisher<[SignalAttachment], Error> in + .tryMap { signalAttachments -> [SignalAttachment] in guard signalAttachments.count > 0 else { - return Fail(error: ShareViewControllerError.assertionError(description: "no valid attachments")) - .eraseToAnyPublisher() + throw ShareViewControllerError.assertionError(description: "no valid attachments") } - return Just(signalAttachments) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return signalAttachments } .shareReplay(1) .eraseToAnyPublisher() diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 43906594e..52b366fcc 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -189,7 +189,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView NotificationCenter.default.post(name: Database.resumeNotification, object: self) Storage.shared - .writePublisher { db -> MessageSender.PreparedSendData in + .writePublisher(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> MessageSender.PreparedSendData in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { throw MessageSenderError.noThread } @@ -248,7 +248,12 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView in: thread ) } - .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } + .flatMap { + MessageSender.performUploadsIfNeeded( + queue: DispatchQueue.global(qos: .userInitiated), + preparedSendData: $0 + ) + } .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift b/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift index 25e71d9ff..869a20b82 100644 --- a/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift +++ b/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift @@ -4,18 +4,27 @@ import Foundation import Sodium import SessionUtilitiesKit -public class DeleteAllBeforeResponse: SnodeRecursiveResponse { - // MARK: - Convenience +public class DeleteAllBeforeResponse: SnodeRecursiveResponse {} + +// MARK: - ValidatableResponse + +extension DeleteAllBeforeResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } internal func validResultMap( + sodium: Sodium, userX25519PublicKey: String, - beforeMs: UInt64, - sodium: Sodium - ) -> [String: Bool] { - return swarm.reduce(into: [:]) { result, next in + validationData: UInt64 + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in guard !next.value.failed, - let encodedSignature: Data = Data(base64Encoded: next.value.signatureBase64) + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) else { result[next.key] = false @@ -32,7 +41,7 @@ public class DeleteAllBeforeResponse: SnodeRecursiveResponse { - // MARK: - Convenience - - internal func validResultMap( - userX25519PublicKey: String, - timestampMs: UInt64, - sodium: Sodium - ) -> [String: Bool] { - return swarm.reduce(into: [:]) { result, next in - guard - !next.value.failed, - let encodedSignature: Data = Data(base64Encoded: next.value.signatureBase64) - else { - result[next.key] = false - - if let reason: String = next.value.reason, let statusCode: Int = next.value.code { - SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") - } - else { - SNLog("Couldn't delete data from: \(next.key).") - } - return - } - - /// Signature of `( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] )` - /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `DELETEDHASH` - /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) - let verificationBytes: [UInt8] = userX25519PublicKey.bytes - .appending(contentsOf: "\(timestampMs)".data(using: .ascii)?.bytes) - .appending(contentsOf: next.value.deleted.joined().bytes) - - result[next.key] = sodium.sign.verify( - message: verificationBytes, - publicKey: Data(hex: next.key).bytes, - signature: encodedSignature.bytes - ) - } - } -} +public class DeleteAllMessagesResponse: SnodeRecursiveResponse {} // MARK: - SwarmItem @@ -78,3 +40,52 @@ public extension DeleteAllMessagesResponse { } } } + +// MARK: - ValidatableResponse + +extension DeleteAllMessagesResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: UInt64 + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] )` + /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `DELETEDHASH` + /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) + .appending(contentsOf: next.value.deleted.joined().bytes) + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/DeleteMessagesResponse.swift b/SessionSnodeKit/Models/DeleteMessagesResponse.swift index 50fedb56e..180d68498 100644 --- a/SessionSnodeKit/Models/DeleteMessagesResponse.swift +++ b/SessionSnodeKit/Models/DeleteMessagesResponse.swift @@ -4,43 +4,7 @@ import Foundation import Sodium import SessionUtilitiesKit -public class DeleteMessagesResponse: SnodeRecursiveResponse { - // MARK: - Convenience - - internal func validResultMap( - userX25519PublicKey: String, - serverHashes: [String], - sodium: Sodium - ) -> [String: Bool] { - return swarm.reduce(into: [:]) { result, next in - guard - !next.value.failed, - let encodedSignature: Data = Data(base64Encoded: next.value.signatureBase64) - else { - result[next.key] = false - - if let reason: String = next.value.reason, let statusCode: Int = next.value.code { - SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") - } - else { - SNLog("Couldn't delete data from: \(next.key).") - } - return - } - - /// The signature format is `( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] )` - let verificationBytes: [UInt8] = userX25519PublicKey.bytes - .appending(contentsOf: serverHashes.joined().bytes) - .appending(contentsOf: next.value.deleted.joined().bytes) - - result[next.key] = sodium.sign.verify( - message: verificationBytes, - publicKey: Data(hex: next.key).bytes, - signature: encodedSignature.bytes - ) - } - } -} +public class DeleteMessagesResponse: SnodeRecursiveResponse {} // MARK: - SwarmItem @@ -57,9 +21,56 @@ public extension DeleteMessagesResponse { required init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - deleted = try container.decode([String].self, forKey: .deleted) + deleted = ((try? container.decode([String].self, forKey: .deleted)) ?? []) try super.init(from: decoder) } } } + +// MARK: - ValidatableResponse + +extension DeleteMessagesResponse: ValidatableResponse { + typealias ValidationData = [String] + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: [String] + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(next.key).") + } + return + } + + /// The signature format is `( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] )` + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: validationData.joined().bytes) + .appending(contentsOf: next.value.deleted.joined().bytes) + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/GetMessagesRequest.swift b/SessionSnodeKit/Models/GetMessagesRequest.swift index 8f17d949d..e338874f8 100644 --- a/SessionSnodeKit/Models/GetMessagesRequest.swift +++ b/SessionSnodeKit/Models/GetMessagesRequest.swift @@ -7,10 +7,14 @@ extension SnodeAPI { enum CodingKeys: String, CodingKey { case lastHash = "last_hash" case namespace + case maxCount = "max_count" + case maxSize = "max_size" } let lastHash: String let namespace: SnodeAPI.Namespace? + let maxCount: Int64? + let maxSize: Int64? // MARK: - Init @@ -21,10 +25,14 @@ extension SnodeAPI { subkey: String?, timestampMs: UInt64, ed25519PublicKey: [UInt8], - ed25519SecretKey: [UInt8] + ed25519SecretKey: [UInt8], + maxCount: Int64? = nil, + maxSize: Int64? = nil ) { self.lastHash = lastHash self.namespace = namespace + self.maxCount = maxCount + self.maxSize = maxSize super.init( pubkey: pubkey, @@ -42,6 +50,8 @@ extension SnodeAPI { try container.encode(lastHash, forKey: .lastHash) try container.encodeIfPresent(namespace, forKey: .namespace) + try container.encodeIfPresent(maxCount, forKey: .maxCount) + try container.encodeIfPresent(maxSize, forKey: .maxSize) try super.encode(to: encoder) } diff --git a/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift b/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift index 644cdcafc..70dc7aa3a 100644 --- a/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift +++ b/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift @@ -9,11 +9,15 @@ extension SnodeAPI { case pubkey case lastHash = "last_hash" case namespace + case maxCount = "max_count" + case maxSize = "max_size" } let pubkey: String let lastHash: String let namespace: SnodeAPI.Namespace? + let maxCount: Int64? + let maxSize: Int64? // MARK: - Coding @@ -23,6 +27,8 @@ extension SnodeAPI { try container.encode(pubkey, forKey: .pubkey) try container.encode(lastHash, forKey: .lastHash) try container.encodeIfPresent(namespace, forKey: .namespace) + try container.encodeIfPresent(maxCount, forKey: .maxCount) + try container.encodeIfPresent(maxSize, forKey: .maxSize) } } } diff --git a/SessionSnodeKit/Models/RevokeSubkeyResponse.swift b/SessionSnodeKit/Models/RevokeSubkeyResponse.swift index f633fc4ed..867e4eb0e 100644 --- a/SessionSnodeKit/Models/RevokeSubkeyResponse.swift +++ b/SessionSnodeKit/Models/RevokeSubkeyResponse.swift @@ -4,24 +4,33 @@ import Foundation import Sodium import SessionUtilitiesKit -public class RevokeSubkeyResponse: SnodeRecursiveResponse { - // MARK: - Convenience +public class RevokeSubkeyResponse: SnodeRecursiveResponse {} + +// MARK: - ValidatableResponse + +extension RevokeSubkeyResponse: ValidatableResponse { + typealias ValidationData = String + typealias ValidationResponse = Bool - internal func validateResult( + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } + + internal func validResultMap( + sodium: Sodium, userX25519PublicKey: String, - subkeyToRevoke: String, - sodium: Sodium - ) throws { - try swarm.forEach { snodePublicKey, swarmItem in + validationData: String + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = try swarm.reduce(into: [:]) { result, next in guard - !swarmItem.failed, - let encodedSignature: Data = Data(base64Encoded: swarmItem.signatureBase64) + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) else { - if let reason: String = swarmItem.reason, let statusCode: Int = swarmItem.code { - SNLog("Couldn't revoke subkey from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't revoke subkey from: \(next.key) due to error: \(reason) (\(statusCode)).") } else { - SNLog("Couldn't revoke subkey from: \(snodePublicKey).") + SNLog("Couldn't revoke subkey from: \(next.key).") } return } @@ -29,15 +38,19 @@ public class RevokeSubkeyResponse: SnodeRecursiveResponse { /// Signature of `( PUBKEY_HEX || SUBKEY_TAG_BYTES )` where `SUBKEY_TAG_BYTES` is the /// requested subkey tag for revocation let verificationBytes: [UInt8] = userX25519PublicKey.bytes - .appending(contentsOf: subkeyToRevoke.bytes) + .appending(contentsOf: validationData.bytes) let isValid: Bool = sodium.sign.verify( message: verificationBytes, - publicKey: Data(hex: snodePublicKey).bytes, + publicKey: Data(hex: next.key).bytes, signature: encodedSignature.bytes ) // If the update signature is invalid then we want to fail here guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + + result[next.key] = isValid } + + return try Self.validated(map: validationMap) } } diff --git a/SessionSnodeKit/Models/SendMessageRequest.swift b/SessionSnodeKit/Models/SendMessageRequest.swift index 91831f155..5058382df 100644 --- a/SessionSnodeKit/Models/SendMessageRequest.swift +++ b/SessionSnodeKit/Models/SendMessageRequest.swift @@ -6,7 +6,6 @@ extension SnodeAPI { public class SendMessageRequest: SnodeAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case namespace - case signatureTimestamp = "timestamp"//"sig_timestamp" // TODO: Add this back once the snodes are fixed!! } let message: SnodeMessage @@ -39,22 +38,22 @@ extension SnodeAPI { override public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try super.encode(to: encoder) - - /// **Note:** We **MUST** do the `message.encode` after we call `super.encode` because it will - /// override the `timestampMs` value with the value in the message (if we do it the other way around then - /// the the API call timestamp will be sent instead which is incorrect. For this specific request type we have a - /// separate `signatureTimestamp` value to store the timestamp used to generate the signature + /// **Note:** We **MUST** do the `message.encode` before we call `super.encode` because otherwise + /// it will override the `timestampMs` value with the value in the message which is incorrect - we actually want the + /// `timestampMs` value at the time the request was made so that older messages stuck in the job queue don't + /// end up failing due to being outside the approved timestamp window (clients use the timestamp within the message + /// data rather than this one anyway) try message.encode(to: encoder) try container.encode(namespace, forKey: .namespace) - try container.encode(timestampMs, forKey: .signatureTimestamp) + + try super.encode(to: encoder) } // MARK: - Abstract Methods override func generateSignature() throws -> [UInt8] { - /// Ed25519 signature of `("store" || namespace || sig_timestamp)`, where namespace and - /// `sig_timestamp` are the base10 expression of the namespace and `sig_timestamp` values. Must be + /// Ed25519 signature of `("store" || namespace || timestamp)`, where namespace and + /// `timestamp` are the base10 expression of the namespace and `timestamp` values. Must be /// base64 encoded for json requests; binary for OMQ requests. For non-05 type pubkeys (i.e. non /// session ids) the signature will be verified using `pubkey`. For 05 pubkeys, see the following /// option. diff --git a/SessionSnodeKit/Models/SendMessageResponse.swift b/SessionSnodeKit/Models/SendMessageResponse.swift index 052188a0e..2e21c8d23 100644 --- a/SessionSnodeKit/Models/SendMessageResponse.swift +++ b/SessionSnodeKit/Models/SendMessageResponse.swift @@ -1,6 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Sodium +import SessionUtilitiesKit public class SendMessagesResponse: SnodeRecursiveResponse { private enum CodingKeys: String, CodingKey { @@ -30,7 +32,7 @@ public extension SendMessagesResponse { case already } - public let hash: String + public let hash: String? /// `true` if a message with this hash was already stored /// @@ -42,10 +44,56 @@ public extension SendMessagesResponse { required init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - hash = try container.decode(String.self, forKey: .hash) + hash = try? container.decode(String.self, forKey: .hash) already = ((try? container.decode(Bool.self, forKey: .already)) ?? false) try super.init(from: decoder) } } } + +// MARK: - ValidatableResponse + +extension SendMessagesResponse: ValidatableResponse { + typealias ValidationData = Void + typealias ValidationResponse = Bool + + /// Half of the responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -2 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: Void + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64), + let hash: String = next.value.hash + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't store message on: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't store message on: \(next.key).") + } + return + } + + /// Signature of `hash` signed by the node's ed25519 pubkey + let verificationBytes: [UInt8] = hash.bytes + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/SnodeSwarmItem.swift b/SessionSnodeKit/Models/SnodeSwarmItem.swift index 456b0ed86..72fc5b003 100644 --- a/SessionSnodeKit/Models/SnodeSwarmItem.swift +++ b/SessionSnodeKit/Models/SnodeSwarmItem.swift @@ -13,8 +13,9 @@ public class SnodeSwarmItem: Codable { case badPeerResponse = "bad_peer_response" case queryFailure = "query_failure" } - - public let signatureBase64: String + + /// Should be present as long as the request didn't fail + public let signatureBase64: String? /// `true` if the request failed, possibly accompanied by one of the following: `timeout`, `code`, /// `reason`, `badPeerResponse`, `queryFailure` @@ -40,7 +41,7 @@ public class SnodeSwarmItem: Codable { public required init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - signatureBase64 = try container.decode(String.self, forKey: .signatureBase64) + signatureBase64 = try? container.decode(String.self, forKey: .signatureBase64) failed = ((try? container.decode(Bool.self, forKey: .failed)) ?? false) timeout = try? container.decode(Bool.self, forKey: .timeout) code = try? container.decode(Int.self, forKey: .code) diff --git a/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift b/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift index 7deba77d5..389199565 100644 --- a/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift +++ b/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift @@ -4,50 +4,7 @@ import Foundation import Sodium import SessionUtilitiesKit -public class UpdateExpiryAllResponse: SnodeRecursiveResponse { - // MARK: - Convenience - - internal func validResultMap( - userX25519PublicKey: String, - expiryMs: UInt64, - sodium: Sodium - ) throws -> [String: [String]] { - return try swarm.reduce(into: [:]) { result, next in - guard - !next.value.failed, - let encodedSignature: Data = Data(base64Encoded: next.value.signatureBase64) - else { - result[next.key] = [] - - if let reason: String = next.value.reason, let statusCode: Int = next.value.code { - SNLog("Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") - } - else { - SNLog("Couldn't update expiry from: \(next.key).") - } - return - } - - /// Signature of `( PUBKEY_HEX || EXPIRY || UPDATED[0] || ... || UPDATED[N] )` - /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `UPDATED` - /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) - let verificationBytes: [UInt8] = userX25519PublicKey.bytes - .appending(contentsOf: "\(expiryMs)".data(using: .ascii)?.bytes) - .appending(contentsOf: next.value.updated.joined().bytes) - - let isValid: Bool = sodium.sign.verify( - message: verificationBytes, - publicKey: Data(hex: next.key).bytes, - signature: encodedSignature.bytes - ) - - // If the update signature is invalid then we want to fail here - guard isValid else { throw SnodeAPIError.signatureVerificationFailed } - - result[next.key] = next.value.updated - } - } -} +public class UpdateExpiryAllResponse: SnodeRecursiveResponse {} // MARK: - SwarmItem @@ -83,3 +40,57 @@ public extension UpdateExpiryAllResponse { } } } + +// MARK: - ValidatableResponse + +extension UpdateExpiryAllResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = [String] + + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: UInt64 + ) throws -> [String: [String]] { + let validationMap: [String: [String]] = try swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = [] + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't update expiry from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || EXPIRY || UPDATED[0] || ... || UPDATED[N] )` + /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `UPDATED` + /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) + .appending(contentsOf: next.value.updated.joined().bytes) + + let isValid: Bool = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + + // If the update signature is invalid then we want to fail here + guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + + result[next.key] = next.value.updated + } + + return try Self.validated(map: validationMap, totalResponseCount: swarm.count) + } +} diff --git a/SessionSnodeKit/Models/UpdateExpiryRequest.swift b/SessionSnodeKit/Models/UpdateExpiryRequest.swift index 4add9bd5d..388b7a964 100644 --- a/SessionSnodeKit/Models/UpdateExpiryRequest.swift +++ b/SessionSnodeKit/Models/UpdateExpiryRequest.swift @@ -7,16 +7,37 @@ extension SnodeAPI { enum CodingKeys: String, CodingKey { case messageHashes = "messages" case expiryMs = "expiry" + case shorten + case extend } + /// Array of message hash strings (as provided by the storage server) to update. Messages can be from any namespace(s) let messageHashes: [String] + + /// The new expiry timestamp (milliseconds since unix epoch). Must be >= 60s ago. The new expiry can be anywhere from + /// current time up to the maximum TTL (30 days) from now; specifying a later timestamp will be truncated to the maximum let expiryMs: UInt64 + /// If provided and set to true then the expiry is only shortened, but not extended. If the expiry is already at or before the given + /// `expiry` timestamp then expiry will not be changed + /// + /// **Note:** This option is only supported starting at network version 19.3). This option is not permitted when using + /// subkey authentication + let shorten: Bool? + + /// If provided and set to true then the expiry is only extended, but not shortened. If the expiry is already at or beyond + /// the given `expiry` timestamp then expiry will not be changed + /// + /// **Note:** This option is only supported starting at network version 19.3. This option is mutually exclusive of "shorten" + let extend: Bool? + // MARK: - Init public init( messageHashes: [String], expiryMs: UInt64, + shorten: Bool? = nil, + extend: Bool? = nil, pubkey: String, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8], @@ -24,6 +45,8 @@ extension SnodeAPI { ) { self.messageHashes = messageHashes self.expiryMs = expiryMs + self.shorten = shorten + self.extend = extend super.init( pubkey: pubkey, @@ -40,6 +63,8 @@ extension SnodeAPI { try container.encode(messageHashes, forKey: .messageHashes) try container.encode(expiryMs, forKey: .expiryMs) + try container.encodeIfPresent(shorten, forKey: .shorten) + try container.encodeIfPresent(extend, forKey: .extend) try super.encode(to: encoder) } diff --git a/SessionSnodeKit/Models/UpdateExpiryResponse.swift b/SessionSnodeKit/Models/UpdateExpiryResponse.swift index 41f7a5ede..523a842bf 100644 --- a/SessionSnodeKit/Models/UpdateExpiryResponse.swift +++ b/SessionSnodeKit/Models/UpdateExpiryResponse.swift @@ -4,18 +4,52 @@ import Foundation import Sodium import SessionUtilitiesKit -public class UpdateExpiryResponse: SnodeRecursiveResponse { - // MARK: - Convenience +public class UpdateExpiryResponse: SnodeRecursiveResponse {} + +// MARK: - SwarmItem + +public extension UpdateExpiryResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case updated + case expiry + } + + public let updated: [String] + public let expiry: UInt64? + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + updated = ((try? container.decode([String].self, forKey: .updated)) ?? []) + expiry = try? container.decode(UInt64.self, forKey: .expiry) + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension UpdateExpiryResponse: ValidatableResponse { + typealias ValidationData = [String] + typealias ValidationResponse = (hashes: [String], expiry: UInt64) + + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } internal func validResultMap( + sodium: Sodium, userX25519PublicKey: String, - messageHashes: [String], - sodium: Sodium + validationData: [String] ) throws -> [String: (hashes: [String], expiry: UInt64)] { - return try swarm.reduce(into: [:]) { result, next in + let validationMap: [String: (hashes: [String], expiry: UInt64)] = try swarm.reduce(into: [:]) { result, next in guard !next.value.failed, - let encodedSignature: Data = Data(base64Encoded: next.value.signatureBase64) + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) else { result[next.key] = ([], 0) @@ -33,8 +67,8 @@ public class UpdateExpiryResponse: SnodeRecursiveResponse = try decoder.container(keyedBy: CodingKeys.self) - - updated = ((try? container.decode([String].self, forKey: .updated)) ?? []) - expiry = try container.decode(UInt64.self, forKey: .expiry) - - try super.init(from: decoder) + // If we didn't get an `expiry` value from the snode then don't bother adding it to the result + // as it's not valid data + guard let expiry: UInt64 = next.value.expiry else { return } + + result[next.key] = (hashes: next.value.updated, expiry: expiry) } + + return try Self.validated(map: validationMap, totalResponseCount: swarm.count) } } diff --git a/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift index 86f25c0fd..36aa1c995 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift @@ -33,16 +33,8 @@ internal extension OnionRequestAPI { case .snode(let snode): // Need to wrap the payload for snode requests return encode(ciphertext: payload, json: [ "headers" : "" ]) - .flatMap { data -> AnyPublisher in - do { - return Just(try AES.GCM.encrypt(data, for: snode.x25519PublicKey)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } + .tryMap { data -> AES.GCM.EncryptionResult in + try AES.GCM.encrypt(data, for: snode.x25519PublicKey) } .eraseToAnyPublisher() @@ -89,17 +81,7 @@ internal extension OnionRequestAPI { }() return encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters) - .flatMap { data -> AnyPublisher in - do { - return Just(try AES.GCM.encrypt(data, for: x25519PublicKey)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch (let error) { - return Fail(error: error) - .eraseToAnyPublisher() - } - } + .tryMap { data -> AES.GCM.EncryptionResult in try AES.GCM.encrypt(data, for: x25519PublicKey) } .eraseToAnyPublisher() } } diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 34ba3dab6..538733eea 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -72,25 +72,20 @@ public enum OnionRequestAPI: OnionRequestAPIType { return HTTP.execute(.get, url, timeout: timeout) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { responseData -> AnyPublisher in + .tryMap { responseData -> Void in // TODO: Remove JSON usage guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() + throw HTTPError.invalidJSON } guard let version = responseJson["version"] as? String else { - return Fail(error: OnionRequestAPIError.missingSnodeVersion) - .eraseToAnyPublisher() + throw OnionRequestAPIError.missingSnodeVersion } guard version >= "2.0.7" else { SNLog("Unsupported snode version: \(version).") - return Fail(error: OnionRequestAPIError.unsupportedSnodeVersion(version)) - .eraseToAnyPublisher() + throw OnionRequestAPIError.unsupportedSnodeVersion(version) } - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return () } .eraseToAnyPublisher() } diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index b78546548..9ba2c851d 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -73,19 +73,16 @@ public final class SnodeAPI { hasLoadedSnodePool.mutate { $0 = true } } - private static func setSnodePool(to newValue: Set, db: Database? = nil) { + private static func setSnodePool(_ db: Database? = nil, to newValue: Set) { + guard let db: Database = db else { + Storage.shared.write { db in setSnodePool(db, to: newValue) } + return + } + snodePool.mutate { $0 = newValue } - if let db: Database = db { - _ = try? Snode.deleteAll(db) - newValue.forEach { try? $0.save(db) } - } - else { - Storage.shared.write { db in - _ = try? Snode.deleteAll(db) - newValue.forEach { try? $0.save(db) } - } - } + _ = try? Snode.deleteAll(db) + newValue.forEach { try? $0.save(db) } } private static func dropSnodeFromSnodePool(_ snode: Snode) { @@ -172,16 +169,13 @@ public final class SnodeAPI { getSnodePoolPublisher.mutate { $0 = publisher } return publisher - .flatMap { snodePool -> AnyPublisher, Error> in - guard !snodePool.isEmpty else { - return Fail(error: SnodeAPIError.snodePoolUpdatingFailed) - .eraseToAnyPublisher() - } + .tryFlatMap { snodePool -> AnyPublisher, Error> in + guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } return Storage.shared - .writePublisher { db in + .writePublisher(receiveOn: Threading.workQueue) { db in db[.lastSnodePoolRefreshDate] = now - setSnodePool(to: snodePool, db: db) + setSnodePool(db, to: snodePool) return snodePool } @@ -233,22 +227,12 @@ public final class SnodeAPI { associatedWith: nil ) .decoded(as: ONSResolveResponse.self) - .flatMap { _, response -> AnyPublisher in - do { - let result: String = try response.sessionId( - sodium: sodium.wrappedValue, - nameBytes: nameAsData, - nameHashBytes: nameHash - ) - - return Just(result) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } + .tryMap { _, response -> String in + try response.sessionId( + sodium: sodium.wrappedValue, + nameBytes: nameAsData, + nameHashBytes: nameHash + ) } .retry(4) .eraseToAnyPublisher() @@ -257,15 +241,12 @@ public final class SnodeAPI { ) .subscribe(on: Threading.workQueue) .collect() - .flatMap { results -> AnyPublisher in + .tryMap { results -> String in guard results.count == validationCount, Set(results).count == 1 else { - return Fail(error: SnodeAPIError.validationFailed) - .eraseToAnyPublisher() + throw SnodeAPIError.validationFailed } - return Just(results[0]) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return results[0] } .eraseToAnyPublisher() } @@ -311,99 +292,124 @@ public final class SnodeAPI { // MARK: - Retrieve - public static func getMessages( - in namespaces: [SnodeAPI.Namespace], + public static func poll( + namespaces: [SnodeAPI.Namespace], + refreshingConfigHashes: [String] = [], from snode: Snode, associatedWith publicKey: String, using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() let targetPublicKey: String = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey ) - var userED25519KeyPair: Box.KeyPair? return Just(()) .setFailureType(to: Error.self) - .flatMap { _ -> Future<[SnodeAPI.Namespace: String], Error> in - Future<[SnodeAPI.Namespace: String], Error> { resolver in - let namespaceLastHash: [SnodeAPI.Namespace: String] = namespaces - .reduce(into: [:]) { result, namespace in - // Prune expired message hashes for this namespace on this service node - SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( + .map { _ -> [SnodeAPI.Namespace: String] in + namespaces + .reduce(into: [:]) { result, namespace in + // Prune expired message hashes for this namespace on this service node + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( + for: snode, + namespace: namespace, + associatedWith: publicKey + ) + + let maybeLastHash: String? = SnodeReceivedMessageInfo + .fetchLastNotExpired( for: snode, namespace: namespace, associatedWith: publicKey - ) - - let maybeLastHash: String? = SnodeReceivedMessageInfo - .fetchLastNotExpired( - for: snode, - namespace: namespace, - associatedWith: publicKey - )? - .hash - - guard let lastHash: String = maybeLastHash else { return } - - result[namespace] = lastHash - } - - resolver(Result.success(namespaceLastHash)) - } + )? + .hash + + guard let lastHash: String = maybeLastHash else { return } + + result[namespace] = lastHash + } } .flatMap { namespaceLastHash -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> in - let requests: [SnodeAPI.BatchRequest.Info] - - do { - requests = try namespaces - .map { namespace -> SnodeAPI.BatchRequest.Info in - // Check if this namespace requires authentication - guard namespace.requiresReadAuthentication else { - return BatchRequest.Info( - request: SnodeRequest( - endpoint: .getMessages, - body: LegacyGetMessagesRequest( - pubkey: targetPublicKey, - lastHash: (namespaceLastHash[namespace] ?? ""), - namespace: namespace - ) + var requests: [SnodeAPI.BatchRequest.Info] = [] + + // If we have any config hashes to refresh TTLs then add those requests first + if !refreshingConfigHashes.isEmpty { + requests.append( + BatchRequest.Info( + request: SnodeRequest( + endpoint: .expire, + body: UpdateExpiryRequest( + messageHashes: refreshingConfigHashes, + expiryMs: UInt64( + SnodeAPI.currentOffsetTimestampMs() + + (30 * 24 * 60 * 60 * 1000) // 30 days ), - responseType: GetMessagesResponse.self + extend: true, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + subkey: nil // TODO: Need to get this ) - } - - // Generate the signature - guard let keyPair: Box.KeyPair = (userED25519KeyPair ?? Storage.shared.read { db in Identity.fetchUserEd25519KeyPair(db) }) else { - throw SnodeAPIError.signingFailed - } - - userED25519KeyPair = keyPair - + ), + responseType: UpdateExpiryResponse.self + ) + ) + } + + // Determine the maxSize each namespace in the request should take up + let namespaceMaxSizeMap: [SnodeAPI.Namespace: Int64] = SnodeAPI.Namespace.maxSizeMap(for: namespaces) + let fallbackSize: Int64 = (namespaceMaxSizeMap.values.min() ?? 1) + + // Add the various 'getMessages' requests + requests.append( + contentsOf: namespaces.map { namespace -> SnodeAPI.BatchRequest.Info in + // Check if this namespace requires authentication + guard namespace.requiresReadAuthentication else { return BatchRequest.Info( request: SnodeRequest( endpoint: .getMessages, - body: GetMessagesRequest( + body: LegacyGetMessagesRequest( + pubkey: targetPublicKey, lastHash: (namespaceLastHash[namespace] ?? ""), namespace: namespace, - pubkey: targetPublicKey, - subkey: nil, - timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), - ed25519PublicKey: keyPair.publicKey, - ed25519SecretKey: keyPair.secretKey + maxCount: nil, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) ) ), responseType: GetMessagesResponse.self ) } - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - + + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .getMessages, + body: GetMessagesRequest( + lastHash: (namespaceLastHash[namespace] ?? ""), + namespace: namespace, + pubkey: targetPublicKey, + subkey: nil, // TODO: Need to get this + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) + ) + ), + responseType: GetMessagesResponse.self + ) + } + ) + + // Actually send the request let responseTypes = requests.map { $0.responseType } - + return SnodeAPI .send( request: SnodeRequest( @@ -416,19 +422,17 @@ public final class SnodeAPI { ) .decoded(as: responseTypes, using: dependencies) .map { batchResponse -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] in - zip(namespaces, batchResponse.responses) + let messageResponses: [HTTP.BatchSubResponse] = batchResponse.responses + .compactMap { $0 as? HTTP.BatchSubResponse } + + return zip(namespaces, messageResponses) .reduce(into: [:]) { result, next in - guard - let subResponse: HTTP.BatchSubResponse = (next.1 as? HTTP.BatchSubResponse), - let messageResponse: GetMessagesResponse = subResponse.body - else { - return - } + guard let messageResponse: GetMessagesResponse = next.1.body else { return } let namespace: SnodeAPI.Namespace = next.0 result[namespace] = ( - info: subResponse.responseInfo, + info: next.1.responseInfo, data: ( messages: messageResponse.messages .compactMap { rawMessage -> SnodeReceivedMessage? in @@ -449,6 +453,112 @@ public final class SnodeAPI { .eraseToAnyPublisher() } + /// **Note:** This is the direct request to retrieve messages so should be retrieved automatically from the `poll()` method, in order to call + /// this directly remove the `@available` line + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + public static func getMessages( + in namespace: SnodeAPI.Namespace, + from snode: Snode, + associatedWith publicKey: String, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<(info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?), Error> { + let targetPublicKey: String = (Features.useTestnet ? + publicKey.removingIdPrefixIfNeeded() : + publicKey + ) + + return Deferred { + Future { resolver in + // Prune expired message hashes for this namespace on this service node + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( + for: snode, + namespace: namespace, + associatedWith: publicKey + ) + + let maybeLastHash: String? = SnodeReceivedMessageInfo + .fetchLastNotExpired( + for: snode, + namespace: namespace, + associatedWith: publicKey + )? + .hash + + resolver(Result.success(maybeLastHash)) + } + } + .tryFlatMap { lastHash -> AnyPublisher<(info: ResponseInfoType, data: GetMessagesResponse?, lastHash: String?), Error> in + + guard namespace.requiresWriteAuthentication else { + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .getMessages, + body: LegacyGetMessagesRequest( + pubkey: targetPublicKey, + lastHash: (lastHash ?? ""), + namespace: namespace, + maxCount: nil, + maxSize: nil + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: GetMessagesResponse.self, using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + + guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + throw SnodeAPIError.noKeyPair + } + + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .getMessages, + body: GetMessagesRequest( + lastHash: (lastHash ?? ""), + namespace: namespace, + pubkey: targetPublicKey, + subkey: nil, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: GetMessagesResponse.self, using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + .map { info, data, lastHash -> (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?) in + return ( + info: info, + data: data.map { messageResponse -> (messages: [SnodeReceivedMessage], lastHash: String?) in + return ( + messages: messageResponse.messages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash: lastHash + ) + } + ) + } + .eraseToAnyPublisher() + } + // MARK: - Store public static func sendMessage( @@ -460,9 +570,11 @@ public final class SnodeAPI { message.recipient.removingIdPrefixIfNeeded() : message.recipient ) + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let sendTimestamp: UInt64 = UInt64(SnodeAPI.currentOffsetTimestampMs()) // Create a convenience method to send a message to an individual Snode - func sendMessage(to snode: Snode) -> AnyPublisher<(any ResponseInfoType, SendMessagesResponse), Error> { + func sendMessage(to snode: Snode) throws -> AnyPublisher<(any ResponseInfoType, SendMessagesResponse), Error> { guard namespace.requiresWriteAuthentication else { return SnodeAPI .send( @@ -482,8 +594,7 @@ public final class SnodeAPI { } guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { - return Fail(error: SnodeAPIError.noKeyPair) - .eraseToAnyPublisher() + throw SnodeAPIError.noKeyPair } return SnodeAPI @@ -494,7 +605,7 @@ public final class SnodeAPI { message: message, namespace: namespace, subkey: nil, - timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + timestampMs: sendTimestamp, ed25519PublicKey: userED25519KeyPair.publicKey, ed25519SecretKey: userED25519KeyPair.secretKey ) @@ -508,14 +619,16 @@ public final class SnodeAPI { } return getSwarm(for: publicKey) - .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } - return sendMessage(to: snode) + return try sendMessage(to: snode) + .tryMap { info, response -> (ResponseInfoType, SendMessagesResponse) in + try response.validateResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: userX25519PublicKey + ) + } .retry(maxRetryCount) .eraseToAnyPublisher() } @@ -600,12 +713,8 @@ public final class SnodeAPI { let responseTypes = requests.map { $0.responseType } return getSwarm(for: publicKey) - .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return SnodeAPI .send( @@ -645,11 +754,8 @@ public final class SnodeAPI { return getSwarm(for: publicKey) .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher<[String: (hashes: [String], expiry: UInt64)], Error> in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher<[String: (hashes: [String], expiry: UInt64)], Error> in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return SnodeAPI .send( @@ -669,22 +775,12 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: UpdateExpiryResponse.self, using: dependencies) - .flatMap { _, response -> AnyPublisher<[String: (hashes: [String], expiry: UInt64)], Error> in - do { - let result: [String: (hashes: [String], expiry: UInt64)] = try response.validResultMap( - userX25519PublicKey: getUserHexEncodedPublicKey(), - messageHashes: serverHashes, - sodium: sodium.wrappedValue - ) - - return Just(result) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } + .tryMap { _, response -> [String: (hashes: [String], expiry: UInt64)] in + try response.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: serverHashes + ) } .retry(maxRetryCount) .eraseToAnyPublisher() @@ -710,11 +806,8 @@ public final class SnodeAPI { return getSwarm(for: publicKey) .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return SnodeAPI .send( @@ -732,22 +825,14 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: RevokeSubkeyResponse.self, using: dependencies) - .flatMap { _, response -> AnyPublisher in - do { - try response.validateResult( - userX25519PublicKey: getUserHexEncodedPublicKey(), - subkeyToRevoke: subkeyToRevoke, - sodium: sodium.wrappedValue - ) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } + .tryMap { _, response -> Void in + try response.validateResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: subkeyToRevoke + ) - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return () } .retry(maxRetryCount) .eraseToAnyPublisher() @@ -779,15 +864,10 @@ public final class SnodeAPI { .flatMap { swarm -> AnyPublisher<[String: Bool], Error> in Just(()) .setFailureType(to: Error.self) - .flatMap { _ -> AnyPublisher in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryMap { _ -> Snode in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } - return Just(snode) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return snode } .flatMap { snode -> AnyPublisher<[String: Bool], Error> in SnodeAPI @@ -809,24 +889,22 @@ public final class SnodeAPI { .subscribe(on: Threading.workQueue) .eraseToAnyPublisher() .decoded(as: DeleteMessagesResponse.self, using: dependencies) - .map { _, response -> [String: Bool] in - let validResultMap: [String: Bool] = response.validResultMap( + .tryMap { _, response -> [String: Bool] in + let validResultMap: [String: Bool] = try response.validResultMap( + sodium: sodium.wrappedValue, userX25519PublicKey: userX25519PublicKey, - serverHashes: serverHashes, - sodium: sodium.wrappedValue + validationData: serverHashes ) - // If at least one service node deleted successfully then we should - // mark the hash as invalid so we don't try to fetch updates using - // that hash going forward (if we do we would end up re-fetching - // all old messages) - if validResultMap.values.contains(true) { - Storage.shared.writeAsync { db in - try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: serverHashes - ) - } + // If `validResultMap` didn't throw then at least one service node + // deleted successfully so we should mark the hash as invalid so we + // don't try to fetch updates using that hash going forward (if we + // do we would end up re-fetching all old messages) + Storage.shared.writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) } return validResultMap @@ -854,11 +932,8 @@ public final class SnodeAPI { return getSwarm(for: userX25519PublicKey) .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher<[String: Bool], Error> in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher<[String: Bool], Error> in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return getNetworkTime(from: snode) .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in @@ -879,14 +954,12 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: DeleteAllMessagesResponse.self, using: dependencies) - .map { _, response in - let validResultMap: [String: Bool] = response.validResultMap( + .tryMap { _, response -> [String: Bool] in + try response.validResultMap( + sodium: sodium.wrappedValue, userX25519PublicKey: userX25519PublicKey, - timestampMs: timestampMs, - sodium: sodium.wrappedValue + validationData: timestampMs ) - - return validResultMap } .eraseToAnyPublisher() } @@ -912,11 +985,8 @@ public final class SnodeAPI { return getSwarm(for: userX25519PublicKey) .subscribe(on: Threading.workQueue) - .flatMap { swarm -> AnyPublisher<[String: Bool], Error> in - guard let snode: Snode = swarm.randomElement() else { - return Fail(error: SnodeAPIError.generic) - .eraseToAnyPublisher() - } + .tryFlatMap { swarm -> AnyPublisher<[String: Bool], Error> in + guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return getNetworkTime(from: snode) .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in @@ -938,14 +1008,12 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: DeleteAllBeforeResponse.self, using: dependencies) - .map { _, response in - let validResultMap: [String: Bool] = response.validResultMap( + .tryMap { _, response -> [String: Bool] in + try response.validResultMap( + sodium: sodium.wrappedValue, userX25519PublicKey: userX25519PublicKey, - beforeMs: beforeMs, - sodium: sodium.wrappedValue + validationData: beforeMs ) - - return validResultMap } .eraseToAnyPublisher() } @@ -1104,20 +1172,15 @@ public final class SnodeAPI { } ) .collect() - .flatMap { results -> AnyPublisher, Error> in + .tryMap { results -> Set in let result: Set = results.reduce(Set()) { prev, next in prev.intersection(next) } // We want the snodes to agree on at least this many snodes - guard result.count > 24 else { - return Fail(error: SnodeAPIError.inconsistentSnodePools) - .eraseToAnyPublisher() - } + guard result.count > 24 else { throw SnodeAPIError.inconsistentSnodePools } // Limit the snode pool size to 256 so that we don't go too long without // refreshing it - return Just(Set(result.prefix(256))) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return Set(result.prefix(256)) } .eraseToAnyPublisher() } diff --git a/SessionSnodeKit/Types/SnodeAPIError.swift b/SessionSnodeKit/Types/SnodeAPIError.swift index 07e3c9366..a7ec7050f 100644 --- a/SessionSnodeKit/Types/SnodeAPIError.swift +++ b/SessionSnodeKit/Types/SnodeAPIError.swift @@ -12,6 +12,7 @@ public enum SnodeAPIError: LocalizedError { case signatureVerificationFailed case invalidIP case emptySnodePool + case responseFailedValidation // ONS case decryptionFailed @@ -29,6 +30,7 @@ public enum SnodeAPIError: LocalizedError { case .signatureVerificationFailed: return "Failed to verify the signature." case .invalidIP: return "Invalid IP." case .emptySnodePool: return "Service Node pool is empty." + case .responseFailedValidation: return "Response failed validation." // ONS case .decryptionFailed: return "Couldn't decrypt ONS name." diff --git a/SessionSnodeKit/Types/SnodeAPINamespace.swift b/SessionSnodeKit/Types/SnodeAPINamespace.swift index d5f559217..84ee98a3e 100644 --- a/SessionSnodeKit/Types/SnodeAPINamespace.swift +++ b/SessionSnodeKit/Types/SnodeAPINamespace.swift @@ -9,7 +9,7 @@ public extension SnodeAPI { case configUserProfile = 2 case configContacts = 3 case configConvoInfoVolatile = 4 - case configGroups = 5 + case configUserGroups = 5 case configClosedGroupInfo = 11 case legacyClosedGroup = -10 @@ -37,5 +37,52 @@ public extension SnodeAPI { default: return "\(self.rawValue)" } } + + /// When performing a batch request we want to try to use the amount of data available in the response as effectively as possible + /// this priority allows us to split the response effectively between the number of namespaces we are requesting from where + /// namespaces with the same priority will be given the same response size divider, for example: + /// ``` + /// default = 1 + /// config1, config2 = 2 + /// config3, config4 = 3 + /// + /// Response data split: + /// _____________________________ + /// | | + /// | default | + /// |_____________________________| + /// | | | config3 | + /// | config1 | config2 | config4 | + /// |_________|_________|_________| + /// + var batchRequestSizePriority: Int64 { + switch self { + case .`default`, .legacyClosedGroup: return 10 + + case .configUserProfile, .configContacts, + .configConvoInfoVolatile, .configUserGroups, + .configClosedGroupInfo: + return 1 + } + } + + static func maxSizeMap(for namespaces: [Namespace]) -> [Namespace: Int64] { + var lastSplit: Int64 = 1 + let namespacePriorityGroups: [Int64: [Namespace]] = namespaces + .grouped { $0.batchRequestSizePriority } + let lowestPriority: Int64 = (namespacePriorityGroups.keys.min() ?? 1) + + return namespacePriorityGroups + .map { $0 } + .sorted(by: { lhs, rhs -> Bool in lhs.key > rhs.key }) + .flatMap { priority, namespaces -> [(namespace: Namespace, maxSize: Int64)] in + lastSplit *= Int64(namespaces.count + (priority == lowestPriority ? 0 : 1)) + + return namespaces.map { ($0, lastSplit) } + } + .reduce(into: [:]) { result, next in + result[next.namespace] = -next.maxSize + } + } } } diff --git a/SessionSnodeKit/Types/ValidatableResponse.swift b/SessionSnodeKit/Types/ValidatableResponse.swift new file mode 100644 index 000000000..67e573a37 --- /dev/null +++ b/SessionSnodeKit/Types/ValidatableResponse.swift @@ -0,0 +1,86 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +internal protocol ValidatableResponse { + associatedtype ValidationData + associatedtype ValidationResponse + + /// This valid controls the number of successful responses for a response to be considered "valid", a + /// positive number indicates an exact number of responses required whereas a negative number indicates + /// a dividing factor, eg. + /// 2 = Two nodes need to have returned success responses + /// -2 = 50% of the nodes need to have returned success responses + /// -4 = 25% of the nodes need to have returned success responses + static var requiredSuccessfulResponses: Int { get } + + static func validated( + map validResultMap: [String: ValidationResponse], + totalResponseCount: Int + ) throws -> [String: ValidationResponse] + + func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: ValidationData + ) throws -> [String: ValidationResponse] + + func validateResultMap(sodium: Sodium, userX25519PublicKey: String, validationData: ValidationData) throws +} + +// MARK: - Convenience + +internal extension ValidatableResponse { + func validateResultMap(sodium: Sodium, userX25519PublicKey: String, validationData: ValidationData) throws { + _ = try validResultMap( + sodium: sodium, + userX25519PublicKey: userX25519PublicKey, + validationData: validationData + ) + } + + static func validated( + map validResultMap: [String: ValidationResponse], + totalResponseCount: Int + ) throws -> [String: ValidationResponse] { + let numSuccessResponses: Int = validResultMap.count + let successPercentage: CGFloat = (CGFloat(numSuccessResponses) / CGFloat(totalResponseCount)) + + guard + ( // Positive value is an exact number comparison + Self.requiredSuccessfulResponses >= 0 && + numSuccessResponses >= Self.requiredSuccessfulResponses + ) || ( + // Negative value is a "divisor" for a percentage comparison + Self.requiredSuccessfulResponses < 0 && + successPercentage >= abs(1 / CGFloat(Self.requiredSuccessfulResponses)) + ) + else { throw SnodeAPIError.responseFailedValidation } + + return validResultMap + } +} + +internal extension ValidatableResponse where ValidationData == Void { + func validResultMap(sodium: Sodium, userX25519PublicKey: String) throws -> [String: ValidationResponse] { + return try validResultMap(sodium: sodium, userX25519PublicKey: userX25519PublicKey, validationData: ()) + } + + func validateResultMap(sodium: Sodium, userX25519PublicKey: String) throws { + _ = try validResultMap( + sodium: sodium, + userX25519PublicKey: userX25519PublicKey, + validationData: () + ) + } +} + +internal extension ValidatableResponse where ValidationResponse == Bool { + static func validated(map validResultMap: [String: Bool]) throws -> [String: Bool] { + return try validated( + map: validResultMap.filter { $0.value }, + totalResponseCount: validResultMap.count + ) + } +} diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index e88705002..da4ca0e8f 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -429,14 +429,14 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", - variant: .legacyClosedGroup + variant: .legacyGroup ).insert(db) } viewModel = ThreadSettingsViewModel( dependencies: dependencies, threadId: "TestId", - threadVariant: .legacyClosedGroup, + threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true } @@ -471,14 +471,14 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", - variant: .openGroup + variant: .community ).insert(db) } viewModel = ThreadSettingsViewModel( dependencies: dependencies, threadId: "TestId", - threadVariant: .openGroup, + threadVariant: .community, didTriggerSearch: { didTriggerSearchCallbackTriggered = true } diff --git a/SessionUIKit/Utilities/UIContextualAction+Theming.swift b/SessionUIKit/Utilities/UIContextualAction+Theming.swift index 408ce8d36..c9f2bd151 100644 --- a/SessionUIKit/Utilities/UIContextualAction+Theming.swift +++ b/SessionUIKit/Utilities/UIContextualAction+Theming.swift @@ -87,6 +87,11 @@ public extension UIContextualAction { label.numberOfLines = (title.components(separatedBy: " ").count > 1 ? 2 : 1) label.frame = CGRect( origin: .zero, + // Note: It looks like there is a semi-max width of 68px for images in the swipe actions + // if the image ends up larger then there an odd behaviour can occur where 8/10 times the + // image is scaled down to fit, but ocassionally (primarily if you hide the action and + // immediately swipe to show it again once the cell hits the edge of the screen) the image + // won't be scaled down but will be full size - appearing as if two different images are used size: label.sizeThatFits(CGSize(width: 68, height: 999)) ) label.set(.width, to: label.frame.width) diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index 9aef2ea5c..a49341ae4 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -74,6 +74,25 @@ public extension Publisher { } .eraseToAnyPublisher() } + + func tryFlatMap( + maxPublishers: Subscribers.Demand = .unlimited, + _ transform: @escaping (Self.Output) throws -> P + ) -> AnyPublisher where T == P.Output, P : Publisher, P.Failure == Error { + return self + .mapError { $0 } + .flatMap(maxPublishers: maxPublishers) { output -> AnyPublisher in + do { + return try transform(output) + .eraseToAnyPublisher() + } + catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } } // MARK: - Convenience @@ -124,17 +143,7 @@ public extension AnyPublisher where Output == Data, Failure == Error { using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { self - .flatMap { data -> AnyPublisher in - do { - return Just(try data.decoded(as: type, using: dependencies)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } + .tryMap { data -> R in try data.decoded(as: type, using: dependencies) } .eraseToAnyPublisher() } } @@ -145,21 +154,10 @@ public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(ResponseInfoType, R), Error> { self - .flatMap { responseInfo, maybeData -> AnyPublisher<(ResponseInfoType, R), Error> in - guard let data: Data = maybeData else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } - do { - return Just((responseInfo, try data.decoded(as: type, using: dependencies))) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + return (responseInfo, try data.decoded(as: type, using: dependencies)) } .eraseToAnyPublisher() } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 1b3e15373..32583c068 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -336,23 +336,29 @@ open class Storage { ) } - open func writePublisher(updates: @escaping (Database) throws -> T) -> AnyPublisher { + open func writePublisher( + receiveOn scheduler: S, + updates: @escaping (Database) throws -> T + ) -> AnyPublisher where S: Scheduler { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return Fail(error: StorageError.databaseInvalid) .eraseToAnyPublisher() } - return dbWriter.writePublisher(updates: updates) + return dbWriter.writePublisher(receiveOn: scheduler, updates: updates) .eraseToAnyPublisher() } - open func readPublisher(value: @escaping (Database) throws -> T) -> AnyPublisher { + open func readPublisher( + receiveOn scheduler: S, + value: @escaping (Database) throws -> T + ) -> AnyPublisher where S: Scheduler { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return Fail(error: StorageError.databaseInvalid) .eraseToAnyPublisher() } - return dbWriter.readPublisher(value: value) + return dbWriter.readPublisher(receiveOn: scheduler, value: value) .eraseToAnyPublisher() } @@ -417,14 +423,20 @@ open class Storage { // MARK: - Combine Extensions public extension Storage { - func readPublisherFlatMap(value: @escaping (Database) throws -> AnyPublisher) -> AnyPublisher { - return readPublisher(value: value) + func readPublisherFlatMap( + receiveOn scheduler: S, + value: @escaping (Database) throws -> AnyPublisher + ) -> AnyPublisher where S: Scheduler { + return readPublisher(receiveOn: scheduler, value: value) .flatMap { resultPublisher -> AnyPublisher in resultPublisher } .eraseToAnyPublisher() } - func writePublisherFlatMap(updates: @escaping (Database) throws -> AnyPublisher) -> AnyPublisher { - return writePublisher(updates: updates) + func writePublisherFlatMap( + receiveOn scheduler: S, + updates: @escaping (Database) throws -> AnyPublisher + ) -> AnyPublisher where S: Scheduler { + return writePublisher(receiveOn: scheduler, updates: updates) .flatMap { resultPublisher -> AnyPublisher in resultPublisher } .eraseToAnyPublisher() } diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 85e9cd6d6..cf350e86d 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -63,11 +63,3 @@ public extension Array where Element == String { return self.reversed() } } - -public extension Array where Element == CChar { - func nullTerminated() -> [Element] { - guard self.last != CChar(0) else { return self } - - return self.appending(CChar(0)) - } -} diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 4294375db..864ddffd5 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -75,20 +75,6 @@ public extension String { } } -// MARK: - Convenience - -public extension String { - /// Initialize with an optional pointer and a spoecific length - init?(pointer: UnsafeRawPointer?, length: Int, encoding: String.Encoding = .utf8) { - guard - let pointer: UnsafeRawPointer = pointer, - let result: String = String(data: Data(bytes: pointer, count: length), encoding: encoding) - else { return nil } - - self = result - } -} - // MARK: - Formatting public extension String { diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 7296a0779..ed058a229 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -844,9 +844,27 @@ private final class JobQueue { detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") + /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to + /// the queue which means an odd situation can occasionally occur where the `finished` event can actually run before the `output` + /// event - this can result in unexpected behaviours (for more information see https://github.com/groue/GRDB.swift/issues/1334) + /// + /// Due to this if a job is meant to run on a concurrent queue then we actually want to create a temporary serial queue just for the execution + /// of that job + let targetQueue: DispatchQueue = { + guard executionType == .concurrent else { return internalQueue } + + return DispatchQueue( + label: "\(self.queueContext)-serial", + qos: self.qosClass, + attributes: [], + autoreleaseFrequency: .inherit, + target: nil + ) + }() + jobExecutor.run( nextJob, - queue: internalQueue, + queue: targetQueue, success: handleJobSucceeded, failure: handleJobFailed, deferred: handleJobDeferred diff --git a/SessionUtilitiesKit/Networking/BatchResponse.swift b/SessionUtilitiesKit/Networking/BatchResponse.swift index 05c8a77fe..78575a775 100644 --- a/SessionUtilitiesKit/Networking/BatchResponse.swift +++ b/SessionUtilitiesKit/Networking/BatchResponse.swift @@ -85,15 +85,11 @@ public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { self - .flatMap { responseInfo, maybeData -> AnyPublisher in + .tryMap { responseInfo, maybeData -> HTTP.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly - guard let data: Data = maybeData else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() + throw HTTPError.parsingFailed } let dataArray: [Data] @@ -103,8 +99,7 @@ public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } guard !requireAllResults || dataArray.count == types.count else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() + throw HTTPError.parsingFailed } case let anyDict as [String: Any]: @@ -115,34 +110,19 @@ public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure !requireAllResults || resultsArray.count == types.count ) - else { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + else { throw HTTPError.parsingFailed } dataArray = resultsArray - default: - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() + default: throw HTTPError.parsingFailed } - do { - // TODO: Remove the 'Swift.' - return Just( - HTTP.BatchResponse( - info: responseInfo, - responses: try Swift.zip(dataArray, types) - .map { data, type in try type.decoded(from: data, using: dependencies) } - ) - ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { - return Fail(error: HTTPError.parsingFailed) - .eraseToAnyPublisher() - } + // TODO: Remove the 'Swift.' + return HTTP.BatchResponse( + info: responseInfo, + responses: try Swift.zip(dataArray, types) + .map { data, type in try type.decoded(from: data, using: dependencies) } + ) } .eraseToAnyPublisher() } diff --git a/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift b/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift index 50ffffaef..186262e49 100644 --- a/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift +++ b/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift @@ -513,22 +513,24 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // Cache miss. // // Asset requests are done queued and performed asynchronously. - return Future { [weak self] resolver in - let assetRequest = ProxiedContentAssetRequest( - assetDescription: assetDescription, - priority: priority, - success: { request, asset in resolver(Result.success((asset, request))) }, - failure: { request in - resolver(Result.failure(HTTPError.generic)) - } - ) - assetRequest.shouldIgnoreSignalProxy = shouldIgnoreSignalProxy - self?.assetRequestQueue.append(assetRequest) - // Process the queue (which may start this request) - // asynchronously so that the caller has time to store - // a reference to the asset request returned by this - // method before its success/failure handler is called. - self?.processRequestQueueAsync() + return Deferred { + Future { [weak self] resolver in + let assetRequest = ProxiedContentAssetRequest( + assetDescription: assetDescription, + priority: priority, + success: { request, asset in resolver(Result.success((asset, request))) }, + failure: { request in + resolver(Result.failure(HTTPError.generic)) + } + ) + assetRequest.shouldIgnoreSignalProxy = shouldIgnoreSignalProxy + self?.assetRequestQueue.append(assetRequest) + // Process the queue (which may start this request) + // asynchronously so that the caller has time to store + // a reference to the asset request returned by this + // method before its success/failure handler is called. + self?.processRequestQueueAsync() + } }.eraseToAnyPublisher() } diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index e4250cb4d..56f3b9fc4 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -201,7 +201,7 @@ public final class ProfilePictureView: UIView { // Otherwise there are conversation-type-specific behaviours switch threadVariant { - case .openGroup: + case .community: switch self.size { case Values.smallProfilePictureSize..(updates: @escaping (Database) throws -> T) -> AnyPublisher { + override func readPublisher( + receiveOn scheduler: S, + value: @escaping (Database) throws -> T + ) -> AnyPublisher where S: Scheduler { + guard let result: T = super.read(value) else { + return Fail(error: StorageError.generic) + .eraseToAnyPublisher() + } + + return Just(result) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + override func writePublisher( + receiveOn scheduler: S, + updates: @escaping (Database) throws -> T + ) -> AnyPublisher where S: Scheduler { guard let result: T = super.write(updates: updates) else { return Fail(error: StorageError.generic) .eraseToAnyPublisher()