From d8ae9669c86e5672b5adb9541bb3d86b1573243f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 26 Jun 2023 18:03:40 +1000 Subject: [PATCH] Fixed a breaking issue and a few other minor bugs Fixed a busted version comparison Fixed an issue where the config dump population wasn't setting the 'created' timestamp for contacts Fixed an issue where the 'SyncPushTokensJob' could run logic on the wrong thread Fixed a bug where the 'scroll to bottom' button wouldn't initial be visible in some cases Fixed a bug where the 'scroll to bottom' button would fade out when there were subsequent pages Fixed a bug where an open group image might not get downloaded in some cases Fixed an issue where we would incorrectly append a wildcard character to the end of a search term that ended in a quotation mark Finished refactoring the OpenGroupAPI to use PreparedSendData --- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 9 +- Session/Conversations/ConversationVC.swift | 23 +- Session/Notifications/SyncPushTokensJob.swift | 1 + Session/Shared/FullConversationCell.swift | 3 +- .../_014_GenerateInitialUserConfigDumps.swift | 3 +- .../Jobs/Types/ConfigurationSyncJob.swift | 2 +- .../Open Groups/Models/SOGSBatchRequest.swift | 132 ++--- .../Open Groups/OpenGroupAPI.swift | 476 +++++++----------- .../Open Groups/OpenGroupManager.swift | 30 +- .../Open Groups/Types/PreparedSendData.swift | 148 +++++- .../Pollers/OpenGroupPoller.swift | 47 +- .../SessionUtil+Contacts.swift | 11 +- .../SessionThreadViewModel.swift | 24 +- .../Models/BatchRequestInfoSpec.swift | 185 +++---- .../Open Groups/OpenGroupAPISpec.swift | 175 ++++--- .../Models/SnodeBatchRequest.swift | 2 +- .../Crypto/CryptoKit+Utilities.swift | 16 +- .../Networking/BatchResponse.swift | 101 ++-- SessionUtilitiesKit/Utilities/Version.swift | 4 +- .../Utilities/VersionSpec.swift | 36 +- 21 files changed, 751 insertions(+), 689 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0a5a4e81a..211bfa740 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6417,7 +6417,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6489,7 +6489,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6554,7 +6554,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6628,7 +6628,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -7536,7 +7536,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7607,7 +7607,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 409; + CURRENT_PROJECT_VERSION = 410; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 6c10b9da8..504067efd 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2088,21 +2088,20 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .readPublisherFlatMap { db -> AnyPublisher in + .readPublisher { db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { throw StorageError.objectNotFound } - return OpenGroupAPI - .userBanAndDeleteAllMessages( + return try OpenGroupAPI + .preparedUserBanAndDeleteAllMessages( db, sessionId: cellViewModel.authorId, in: openGroup.roomToken, on: openGroup.server ) - .map { _ in () } - .eraseToAnyPublisher() } + .flatMap { OpenGroupAPI.send(data: $0) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index c655c3e89..ddadaed3e 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1632,10 +1632,19 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers unreadCountView.isHidden = (unreadCount == 0) } - public func updateScrollToBottom() { - // The initial scroll can trigger this logic but we already mark the initially focused message - // as read so don't run the below until the user actually scrolls after the initial layout - guard self.didFinishInitialLayout else { return } + public func updateScrollToBottom(force: Bool = false) { + // Don't update the scroll button until we have actually setup the initial scroll position to avoid + // any odd flickering or incorrect appearance + guard self.didFinishInitialLayout || force else { return } + + // If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the + // 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without + // this the button will fade out as the user gets close to the bottom of the current page) + guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else { + self.scrollButton.alpha = 1 + self.unreadCountView.alpha = 1 + return + } // Calculate the target opacity for the scroll button let contentOffsetY: CGFloat = tableView.contentOffset.y @@ -1848,17 +1857,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers animated: (self.didFinishInitialLayout && isAnimated) ) - // Need to explicitly call 'scrollViewDidScroll' here as it won't get triggered - // by 'scrollToRow' if a scroll doesn't occur (eg. if there is less than 1 screen - // of messages) - self.scrollViewDidScroll(self.tableView) - // If we haven't finished the initial layout then we want to delay the highlight/markRead slightly // so it doesn't look buggy with the push transition and we know for sure the correct visible cells // have been loaded DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo) self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour) + self?.updateScrollToBottom(force: true) } self.shouldHighlightNextScrollToInteraction = false diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index c11ec93d3..36b32e56a 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -193,6 +193,7 @@ extension SyncPushTokensJob { return Fail(error: error) .eraseToAnyPublisher() } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index c22bcb3b2..9a8d7fa83 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -651,8 +651,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC .map { part -> String in guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - let partRange = (part.index(after: part.startIndex)..(request: Request, responseType: R.Type) { - self.endpoint = request.endpoint - self.responseType = HTTP.BatchSubResponse.self - self.child = Child(request: request) - } - - public init(request: Request) { - self.init( - request: request, - responseType: NoResponse.self - ) - } - } - // MARK: - BatchRequest.Child struct Child: Encodable { @@ -51,76 +32,43 @@ internal extension OpenGroupAPI { case bytes } - let method: HTTPMethod - let path: String - let headers: [String: String]? - - /// The `jsonBodyEncoder` is used to avoid having to make `Child` a generic type (haven't found a good way - /// to keep `Child` encodable using protocols unfortunately so need this work around) - private let jsonBodyEncoder: ((inout KeyedEncodingContainer, CodingKeys) throws -> ())? - private let b64: String? - private let bytes: [UInt8]? - - internal init(request: Request) { - self.method = request.method - self.path = request.urlPathAndParamsString - self.headers = (request.headers.isEmpty ? nil : request.headers.toHTTPHeaders()) - - // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure - // they are encoded correctly so the server knows how to handle them - switch request.body { - case let bodyString as String: - self.jsonBodyEncoder = nil - self.b64 = bodyString - self.bytes = nil - - case let bodyBytes as [UInt8]: - self.jsonBodyEncoder = nil - self.b64 = nil - self.bytes = bodyBytes - - default: - self.jsonBodyEncoder = { [body = request.body] container, key in - try container.encodeIfPresent(body, forKey: key) - } - self.b64 = nil - self.bytes = nil - } - } + let request: ErasedPreparedSendData func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(method, forKey: .method) - try container.encode(path, forKey: .path) - try container.encodeIfPresent(headers, forKey: .headers) - try jsonBodyEncoder?(&container, .json) - try container.encodeIfPresent(b64, forKey: .b64) - try container.encodeIfPresent(bytes, forKey: .bytes) + try request.encodeForBatchRequest(to: encoder) } } } -} - -// MARK: - Convenience - -internal extension AnyPublisher where Output == HTTP.BatchResponse, Failure == Error { - func map( - requests: [OpenGroupAPI.BatchRequest.Info], - toHashMapFor endpointType: E.Type - ) -> AnyPublisher<(info: ResponseInfoType, data: [E: Codable]), Error> { - return self - .map { result -> (info: ResponseInfoType, data: [E: Codable]) in - ( - info: result.info, - data: result.responses.enumerated() - .reduce(into: [:]) { prev, next in - guard let endpoint: E = requests[next.offset].endpoint as? E else { return } - - prev[endpoint] = next.element - } - ) - } - .eraseToAnyPublisher() + + struct BatchResponse: Decodable { + let info: ResponseInfoType + let data: [Endpoint: Decodable] + + public subscript(position: Endpoint) -> Decodable? { + get { return data[position] } + } + + public var count: Int { data.count } + public var keys: Dictionary.Keys { data.keys } + public var values: Dictionary.Values { data.values } + + // MARK: - Initialization + + internal init( + info: ResponseInfoType, + data: [Endpoint: Decodable] + ) { + self.info = info + self.data = data + } + + public init(from decoder: Decoder) throws { +#if DEBUG + preconditionFailure("The `OpenGroupAPI.BatchResponse` type cannot be decoded directly, this is simply here to allow for `PreparedSendData` support") +#else + data = [:] +#endif + + } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 59cce9d4c..6f51415f8 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -26,13 +26,13 @@ public enum OpenGroupAPI { /// - Messages (includes additions and deletions) /// - Inbox for the server /// - Outbox for the server - public static func poll( + public static func preparedPoll( _ db: Database, server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { + ) throws -> PreparedSendData { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) .filter(OpenGroup.Columns.server == server) @@ -51,26 +51,23 @@ public enum OpenGroupAPI { .asRequest(of: Capability.Variant.self) .fetchSet(db)) .defaulting(to: []) + let openGroupRooms: [OpenGroup] = (try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .fetchAll(db)) + .defaulting(to: []) - // Generate the requests - let requestResponseType: [BatchRequest.Info] = [ - BatchRequest.Info( - request: Request( - server: server, - endpoint: .capabilities - ), - responseType: Capabilities.self + let preparedRequests: [ErasedPreparedSendData] = [ + try preparedCapabilities( + db, + server: server, + using: dependencies ) - ] - .appending( + ].appending( // Per-room requests - contentsOf: (try? OpenGroup - .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .fetchAll(db)) - .defaulting(to: []) - .flatMap { openGroup -> [BatchRequest.Info] in + contentsOf: try openGroupRooms + .flatMap { openGroup -> [ErasedPreparedSendData] in let shouldRetrieveRecentMessages: Bool = ( openGroup.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than @@ -82,26 +79,27 @@ public enum OpenGroupAPI { ) return [ - BatchRequest.Info( - request: Request( - server: server, - endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) - ), - responseType: RoomPollInfo.self + try preparedRoomPollInfo( + db, + lastUpdated: openGroup.infoUpdates, + for: openGroup.roomToken, + on: openGroup.server, + using: dependencies ), - BatchRequest.Info( - request: Request( - server: server, - endpoint: (shouldRetrieveRecentMessages ? - .roomMessagesRecent(openGroup.roomToken) : - .roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) - ), - queryParameters: [ - .updateTypes: UpdateTypes.reaction.rawValue, - .reactors: "5" - ] - ), - responseType: [Failable].self + (shouldRetrieveRecentMessages ? + try preparedRecentMessages( + db, + in: openGroup.roomToken, + on: openGroup.server, + using: dependencies + ) : + try preparedMessagesSince( + db, + seqNo: openGroup.sequenceNumber, + in: openGroup.roomToken, + on: openGroup.server, + using: dependencies + ) ) ] } @@ -112,83 +110,73 @@ public enum OpenGroupAPI { !capabilities.contains(.blind) ? [] : [ // Inbox - BatchRequest.Info( - request: Request( - server: server, - endpoint: (lastInboxMessageId == 0 ? - .inbox : - .inboxSince(id: lastInboxMessageId) - ) - ), - responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages + (lastInboxMessageId == 0 ? + try preparedInbox(db, on: server, using: dependencies) : + try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) ), // Outbox - BatchRequest.Info( - request: Request( - server: server, - endpoint: (lastOutboxMessageId == 0 ? - .outbox : - .outboxSince(id: lastOutboxMessageId) - ) - ), - responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages - ) + (lastOutboxMessageId == 0 ? + try preparedOutbox(db, on: server, using: dependencies) : + try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) + ), ] ) ) - return OpenGroupAPI.batch(db, server: server, requests: requestResponseType, using: dependencies) + return try OpenGroupAPI.preparedBatch( + db, + server: server, + requests: preparedRequests, + using: dependencies + ) } /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one /// - /// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which requests will be - /// carried out (for sequential, related requests invoke via `/sequence` instead) + /// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which + /// requests will be carried out (for sequential, related requests invoke via `/sequence` instead) /// - /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. - private static func batch( + /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided + /// with the request body. + private static func preparedBatch( _ db: Database, server: String, - requests: [BatchRequest.Info], + requests: [ErasedPreparedSendData], using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { - let responseTypes = requests.map { $0.responseType } - - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, - endpoint: Endpoint.batch, + endpoint: .batch, body: BatchRequest(requests: requests) ), + responseType: BatchResponse.self, using: dependencies ) - .decoded(as: responseTypes, using: dependencies) - .map(requests: requests, toHashMapFor: Endpoint.self) } - /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests if the previous request - /// returned a non-`2xx` response + /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests + /// if the previous request returned a non-`2xx` response /// - /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because - /// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not - /// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." + /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the + /// ban fails (e.g. because permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the + /// `/batch` endpoint; requests that are not carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." /// - /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were - /// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value - private static func sequence( + /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response + /// list (if requests were stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final + /// response value + private static func preparedSequence( _ db: Database, server: String, - requests: [BatchRequest.Info], + requests: [ErasedPreparedSendData], using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { - let responseTypes = requests.map { $0.responseType } - - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -196,18 +184,17 @@ public enum OpenGroupAPI { endpoint: Endpoint.sequence, body: BatchRequest(requests: requests) ), + responseType: BatchResponse.self, using: dependencies ) - .decoded(as: responseTypes, using: dependencies) - .map(requests: requests, toHashMapFor: Endpoint.self) } // MARK: - Capabilities /// Return the list of server features/capabilities /// - /// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) response - /// will be returned with missing requested capabilities in the `missing` key + /// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) + /// response will be returned with missing requested capabilities in the `missing` key /// /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` @@ -253,11 +240,6 @@ public enum OpenGroupAPI { } /// Returns the details of a single room - /// - /// **Note:** This is the direct request to retrieve a room so should only be called from either the `poll()` or `joinRoom()` methods, in order to call - /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` - /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedRoom( _ db: Database, for roomToken: String, @@ -280,11 +262,6 @@ public enum OpenGroupAPI { /// /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value - /// - /// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call - /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` - /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedRoomPollInfo( _ db: Database, lastUpdated: Int64, @@ -305,51 +282,33 @@ public enum OpenGroupAPI { } public typealias CapabilitiesAndRoomResponse = ( - info: ResponseInfoType, - data: ( - capabilities: (info: ResponseInfoType, data: Capabilities), - room: (info: ResponseInfoType, data: Room) - ) + capabilities: (info: ResponseInfoType, data: Capabilities), + room: (info: ResponseInfoType, data: Room) ) /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// methods for the documented behaviour of each method - public static func capabilitiesAndRoom( + public static func preparedCapabilitiesAndRoom( _ db: Database, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher { - let requestResponseType: [BatchRequest.Info] = [ - // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) - BatchRequest.Info( - request: Request( - server: server, - endpoint: .capabilities - ), - responseType: Capabilities.self - ), - - // And the room info - BatchRequest.Info( - request: Request( - server: server, - endpoint: .room(roomToken) - ), - responseType: Room.self - ) - ] - - return OpenGroupAPI - .sequence( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + // Get the latest capabilities for the server (in case it's a new server or the + // cached ones are stale) + preparedCapabilities(db, server: server, using: dependencies), + preparedRoom(db, for: roomToken, on: server, using: dependencies) + ], using: dependencies ) - .tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> CapabilitiesAndRoomResponse in - let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) - let maybeRoomResponse: Codable? = data + .map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomResponse in + let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) + let maybeRoomResponse: Decodable? = response.data .first(where: { key, _ in switch key { case .room: return true @@ -367,53 +326,34 @@ public enum OpenGroupAPI { else { throw HTTPError.parsingFailed } return ( - info: info, - data: ( - capabilities: (info: capabilitiesInfo, data: capabilities), - room: (info: roomInfo, data: room) - ) + capabilities: (info: capabilitiesInfo, data: capabilities), + room: (info: roomInfo, data: room) ) } - .eraseToAnyPublisher() } /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method - public static func capabilitiesAndRooms( + public static func preparedCapabilitiesAndRooms( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> { - let requestResponseType: [BatchRequest.Info] = [ - // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) - BatchRequest.Info( - request: Request( - server: server, - endpoint: .capabilities - ), - responseType: Capabilities.self - ), - - // And the room info - BatchRequest.Info( - request: Request( - server: server, - endpoint: .rooms - ), - responseType: [Room].self - ) - ] - - return OpenGroupAPI - .sequence( + ) throws -> PreparedSendData<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room]))> { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + // Get the latest capabilities for the server (in case it's a new server or the + // cached ones are stale) + preparedCapabilities(db, server: server, using: dependencies), + preparedRooms(db, server: server, using: dependencies) + ], using: dependencies ) - .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 + .map { (info: ResponseInfoType, response: BatchResponse) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in + let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) + let maybeRooms: HTTP.BatchSubResponse<[Room]>? = response.data .first(where: { key, _ in switch key { case .rooms: return true @@ -434,7 +374,6 @@ public enum OpenGroupAPI { rooms: (info: roomsInfo, data: rooms) ) } - .eraseToAnyPublisher() } // MARK: - Messages @@ -528,6 +467,7 @@ public enum OpenGroupAPI { ) } + /// Remove a message by its message id public static func preparedMessageDelete( _ db: Database, id: Int64, @@ -548,62 +488,75 @@ public enum OpenGroupAPI { ) } - /// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call - /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` - /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// Retrieves recent messages posted to this room + /// + /// Returns the most recent limit messages (100 if no limit is given). This only returns extant messages, and always returns the latest + /// versions: that is, deleted message indicators and pre-editing versions of messages are not returned. Messages are returned in order + /// from most recent to least recent public static func preparedRecentMessages( _ db: Database, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) throws -> PreparedSendData<[Message]> { + ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, - endpoint: .roomMessagesRecent(roomToken) + endpoint: .roomMessagesRecent(roomToken), + queryParameters: [ + .updateTypes: UpdateTypes.reaction.rawValue, + .reactors: "5" + ] ), - responseType: [Message].self, + responseType: [Failable].self, using: dependencies ) } - /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly - /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` - /// method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// Retrieves messages from the room preceding a given id. + /// + /// This endpoint is intended to be used with .../recent to allow a client to retrieve the most recent messages and then walk backwards + /// through batches of ever-older messages. As with .../recent, messages are returned in order from most recent to least recent. + /// + /// As with .../recent, this endpoint does not include deleted messages and always returns the current version, for edited messages. public static func preparedMessagesBefore( _ db: Database, messageId: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) throws -> PreparedSendData<[Message]> { + ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, - endpoint: .roomMessagesBefore(roomToken, id: messageId) + endpoint: .roomMessagesBefore(roomToken, id: messageId), + queryParameters: [ + .updateTypes: UpdateTypes.reaction.rawValue, + .reactors: "5" + ] ), - responseType: [Message].self, + responseType: [Failable].self, using: dependencies ) } - /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the - /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the - /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// Retrieves message updates from a room. This is the main message polling endpoint in SOGS. + /// + /// This endpoint retrieves new, edited, and deleted messages or message reactions posted to this room since the given message + /// sequence counter. Returns limit messages at a time (100 if no limit is given). Returned messages include any new messages, updates + /// to existing messages (i.e. edits), and message deletions made to the room since the given update id. Messages are returned in "update" + /// order, that is, in the order in which the change was applied to the room, from oldest the newest. public static func preparedMessagesSince( _ db: Database, seqNo: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) throws -> PreparedSendData<[Message]> { + ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( db, @@ -612,10 +565,10 @@ public enum OpenGroupAPI { endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, - .reactors: "20" + .reactors: "5" ] ), - responseType: [Message].self, + responseType: [Failable].self, using: dependencies ) } @@ -655,6 +608,7 @@ public enum OpenGroupAPI { // MARK: - Reactions + /// Returns the list of all reactors who have added a particular reaction to a particular message. public static func preparedReactors( _ db: Database, emoji: String, @@ -682,6 +636,10 @@ public enum OpenGroupAPI { ) } + /// Adds a reaction to the given message in this room. The user must have read access in the room. + /// + /// Reactions are short strings of 1-12 unicode codepoints, typically emoji (or character sequences to produce an emoji variant, + /// such as 👨🏿‍🦰, which is composed of 4 unicode "characters" but usually renders as a single emoji "Man: Dark Skin Tone, Red Hair"). public static func preparedReactionAdd( _ db: Database, emoji: String, @@ -709,6 +667,8 @@ public enum OpenGroupAPI { ) } + /// Removes a reaction from a post this room. The user must have read access in the room. This only removes the user's own reaction + /// but does not affect the reactions of other users. public static func preparedReactionDelete( _ db: Database, emoji: String, @@ -736,6 +696,9 @@ public enum OpenGroupAPI { ) } + /// Removes all reactions of all users from a post in this room. The calling must have moderator permissions in the room. This endpoint + /// can either remove a single reaction (e.g. remove all 🍆 reactions) by specifying it after the message id (following a /), or remove all + /// reactions from the post by not including the / suffix of the URL. public static func preparedReactionDeleteAll( _ db: Database, emoji: String, @@ -842,6 +805,12 @@ public enum OpenGroupAPI { // MARK: - Files + /// Uploads a file to a room. + /// + /// Takes the request as binary in the body and takes other properties (specifically the suggested filename) via submitted headers. + /// + /// The user must have upload and posting permissions for the room. The file will have a default lifetime of 1 hour, which is extended + /// to 15 days (by default) when a post referencing the uploaded file is posted or edited. public static func preparedUploadFile( _ db: Database, bytes: [UInt8], @@ -871,6 +840,10 @@ public enum OpenGroupAPI { ) } + /// Retrieves a file uploaded to the room. + /// + /// Retrieves a file via its numeric id from the room, returning the file content directly as the binary response body. The file's suggested + /// filename (as provided by the uploader) is provided in the Content-Disposition header, if available. public static func preparedDownloadFile( _ db: Database, fileId: String, @@ -895,10 +868,7 @@ public enum OpenGroupAPI { /// Retrieves all of the user's current DMs (up to limit) /// - /// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` - /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the - /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// **Note:** `inbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInbox( _ db: Database, on server: String, @@ -918,10 +888,7 @@ public enum OpenGroupAPI { /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// - /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved - /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response - /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// **Note:** `inboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedInboxSince( _ db: Database, id: Int64, @@ -968,10 +935,7 @@ public enum OpenGroupAPI { /// Retrieves all of the user's sent DMs (up to limit) /// - /// **Note:** This is the direct request to retrieve DMs sent by the user for a specific Open Group so should be retrieved automatically - /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of - /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// **Note:** `outbox` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutbox( _ db: Database, on server: String, @@ -991,10 +955,7 @@ public enum OpenGroupAPI { /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages /// - /// **Note:** This is the direct request to retrieve messages requests sent by the user for a specific Open Group since a given messages so - /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure - /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly - @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + /// **Note:** `outboxSince` will return a `304` with an empty response if no messages (hence the optional return type) public static func preparedOutboxSince( _ db: Database, id: Int64, @@ -1207,52 +1168,35 @@ public enum OpenGroupAPI { /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method - public static func userBanAndDeleteAllMessages( + public static func preparedUserBanAndDeleteAllMessages( _ db: Database, sessionId: String, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: ResponseInfoType]), Error> { - let banRequestBody: UserBanRequest = UserBanRequest( - rooms: [roomToken], - global: nil, - timeout: nil - ) - - // Generate the requests - let requestResponseType: [BatchRequest.Info] = [ - BatchRequest.Info( - request: Request( - method: .post, - server: server, - endpoint: .userBan(sessionId), - body: banRequestBody - ) - ), - BatchRequest.Info( - request: Request( - method: .delete, - server: server, - endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) - ) - ) - ] - - return OpenGroupAPI - .sequence( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + preparedUserBan( + db, + sessionId: sessionId, + from: [roomToken], + on: server, + using: dependencies + ), + preparedMessagesDeleteAll( + db, + sessionId: sessionId, + in: roomToken, + on: server, + using: dependencies + ) + ], using: dependencies ) - .map { info, data -> (info: ResponseInfoType, data: [Endpoint: ResponseInfoType]) in - ( - info, - data.compactMapValues { ($0 as? BatchSubResponseType)?.responseInfo } - ) - } - .eraseToAnyPublisher() } // MARK: - Authentication @@ -1388,6 +1332,9 @@ public enum OpenGroupAPI { // MARK: - Convenience + /// Takes the reuqest information and generates a signed `PreparedSendData` pbject which is ready for sending to the API, this + /// method is mainly here so we can separate the preparation of a request, which requires access to the database for signing, from the + /// actual sending of the reuqest to ensure we don't run into any unexpected blocking of the database write thread private static func prepareSendData( _ db: Database, request: Request, @@ -1411,56 +1358,15 @@ public enum OpenGroupAPI { } return PreparedSendData( - request: signedRequest, - endpoint: request.endpoint, - server: request.server, + request: request, + urlRequest: signedRequest, publicKey: publicKey, responseType: responseType, timeout: timeout ) } - private static func send( - _ db: Database, - request: Request, - forceBlinded: Bool = false, - timeout: TimeInterval = HTTP.defaultTimeout, - using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - let urlRequest: URLRequest - - do { - urlRequest = try request.generateUrlRequest() - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - - let maybePublicKey: String? = try? OpenGroup - .select(.publicKey) - .filter(OpenGroup.Columns.server == request.server.lowercased()) - .asRequest(of: String.self) - .fetchOne(db) - - guard let publicKey: String = maybePublicKey else { - return Fail(error: OpenGroupAPIError.noPublicKey) - .eraseToAnyPublisher() - } - - // Attempt to sign the request with the new auth - guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else { - return Fail(error: OpenGroupAPIError.signingFailed) - .eraseToAnyPublisher() - } - - // We want to avoid blocking the db write thread so we dispatch the API call to a different thread - return Just(()) - .setFailureType(to: Error.self) - .flatMap { dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey, timeout: timeout) } - .eraseToAnyPublisher() - } - + /// This method takes in the `PreparedSendData` and actually sends it to the API public static func send( data: PreparedSendData?, using dependencies: SMKDependencies = SMKDependencies() diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 08b585bd5..fee25ffba 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -282,13 +282,9 @@ public final class OpenGroupManager { } .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( + .readPublisher { db in + try OpenGroupAPI + .preparedCapabilitiesAndRoom( db, for: roomToken, on: targetServer, @@ -296,7 +292,8 @@ public final class OpenGroupManager { ) } } - .flatMap { response -> Future in + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { info, response -> Future in Future { resolver in dependencies.storage.write { db in // Add the new open group to libSession @@ -312,14 +309,14 @@ public final class OpenGroupManager { // Store the capabilities first OpenGroupManager.handleCapabilities( db, - capabilities: response.data.capabilities.data, + capabilities: response.capabilities.data, on: targetServer ) // Then the room try OpenGroupManager.handlePollInfo( db, - pollInfo: OpenGroupAPI.RoomPollInfo(room: response.data.room.data), + pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), publicKey: publicKey, for: roomToken, on: targetServer, @@ -1024,17 +1021,18 @@ public final class OpenGroupManager { // Try to retrieve the default rooms 8 times let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage - .readPublisherFlatMap { db in - OpenGroupAPI.capabilitiesAndRooms( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( db, on: OpenGroupAPI.defaultServer, using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .retry(8) - .map { response in + .map { info, response in dependencies.storage.writeAsync { db in // Store the capabilities first OpenGroupManager.handleCapabilities( @@ -1204,6 +1202,12 @@ public final class OpenGroupManager { .shareReplay(1) .eraseToAnyPublisher() + // Automatically subscribe for the roomImage download (want to download regardless of + // whether the upstream subscribes) + publisher + .subscribe(on: dependencies.subscribeQueue) + .sinkUntilComplete() + dependencies.mutableCache.mutate { cache in cache.groupImagePublishers[threadId] = publisher } diff --git a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift index f2798326f..c8b7c4d00 100644 --- a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift +++ b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift @@ -4,28 +4,48 @@ import Foundation import Combine import SessionUtilitiesKit +// MARK: - ErasedPreparedSendData + +public protocol ErasedPreparedSendData { + var endpoint: OpenGroupAPI.Endpoint { get } + var batchResponseTypes: [Decodable.Type] { get } + + func encodeForBatchRequest(to encoder: Encoder) throws +} + +// MARK: - PreparedSendData + public extension OpenGroupAPI { - struct PreparedSendData { + struct PreparedSendData: ErasedPreparedSendData { internal let request: URLRequest - internal let endpoint: Endpoint internal let server: String internal let publicKey: String internal let originalType: Decodable.Type internal let responseType: R.Type internal let timeout: TimeInterval - internal let responseConverter: ((ResponseInfoType, Any) throws -> R) + fileprivate let responseConverter: ((ResponseInfoType, Any) throws -> R) - internal init( - request: URLRequest, - endpoint: Endpoint, - server: String, + // The following types are needed for `BatchRequest` handling + private let method: HTTPMethod + private let path: String + public let endpoint: Endpoint + fileprivate let batchEndpoints: [Endpoint] + public let batchResponseTypes: [Decodable.Type] + + /// The `jsonBodyEncoder` is used to simplify the encoding for `BatchRequest` + private let jsonBodyEncoder: ((inout KeyedEncodingContainer, BatchRequest.Child.CodingKeys) throws -> ())? + private let b64: String? + private let bytes: [UInt8]? + + internal init( + request: Request, + urlRequest: URLRequest, publicKey: String, responseType: R.Type, timeout: TimeInterval ) where R: Decodable { - self.request = request - self.endpoint = endpoint - self.server = server + self.request = urlRequest + self.server = request.server self.publicKey = publicKey self.originalType = responseType self.responseType = responseType @@ -35,26 +55,101 @@ public extension OpenGroupAPI { return validResponse } + + // The following data is needed in this type for handling batch requests + self.method = request.method + self.endpoint = request.endpoint + self.path = request.urlPathAndParamsString + self.batchEndpoints = ((request.body as? BatchRequest)? + .requests + .map { $0.request.endpoint }) + .defaulting(to: []) + self.batchResponseTypes = ((request.body as? BatchRequest)? + .requests + .flatMap { $0.request.batchResponseTypes }) + .defaulting(to: [HTTP.BatchSubResponse.self]) + + // Note: Need to differentiate between JSON, b64 string and bytes body values to ensure + // they are encoded correctly so the server knows how to handle them + switch request.body { + case let bodyString as String: + self.jsonBodyEncoder = nil + self.b64 = bodyString + self.bytes = nil + + case let bodyBytes as [UInt8]: + self.jsonBodyEncoder = nil + self.b64 = nil + self.bytes = bodyBytes + + default: + self.jsonBodyEncoder = { [body = request.body] container, key in + try container.encodeIfPresent(body, forKey: key) + } + self.b64 = nil + self.bytes = nil + } } private init( request: URLRequest, - endpoint: Endpoint, server: String, publicKey: String, originalType: U.Type, responseType: R.Type, timeout: TimeInterval, - responseConverter: @escaping (ResponseInfoType, Any) throws -> R + responseConverter: @escaping (ResponseInfoType, Any) throws -> R, + method: HTTPMethod, + endpoint: Endpoint, + path: String, + batchEndpoints: [Endpoint], + batchResponseTypes: [Decodable.Type], + jsonBodyEncoder: ((inout KeyedEncodingContainer, BatchRequest.Child.CodingKeys) throws -> ())?, + b64: String?, + bytes: [UInt8]? ) { self.request = request - self.endpoint = endpoint self.server = server self.publicKey = publicKey self.originalType = originalType self.responseType = responseType self.timeout = timeout self.responseConverter = responseConverter + + // The following data is needed in this type for handling batch requests + self.method = method + self.endpoint = endpoint + self.path = path + self.batchEndpoints = batchEndpoints + self.batchResponseTypes = batchResponseTypes + self.jsonBodyEncoder = jsonBodyEncoder + self.b64 = b64 + self.bytes = bytes + } + + // MARK: - ErasedPreparedSendData + + public func encodeForBatchRequest(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: BatchRequest.Child.CodingKeys.self) + + // Exclude request signature headers (not used for sub-requests) + let batchRequestHeaders: [String: String] = (request.allHTTPHeaderFields ?? [:]) + .filter { key, _ in + key.lowercased() != HTTPHeader.sogsPubKey.lowercased() && + key.lowercased() != HTTPHeader.sogsTimestamp.lowercased() && + key.lowercased() != HTTPHeader.sogsNonce.lowercased() && + key.lowercased() != HTTPHeader.sogsSignature.lowercased() + } + + if !batchRequestHeaders.isEmpty { + try container.encode(batchRequestHeaders, forKey: .headers) + } + + try container.encode(method, forKey: .method) + try container.encode(path, forKey: .path) + try jsonBodyEncoder?(&container, .json) + try container.encodeIfPresent(b64, forKey: .b64) + try container.encodeIfPresent(bytes, forKey: .bytes) } } } @@ -63,7 +158,6 @@ public extension OpenGroupAPI.PreparedSendData { func map(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData { return OpenGroupAPI.PreparedSendData( request: request, - endpoint: endpoint, server: server, publicKey: publicKey, originalType: originalType, @@ -73,7 +167,15 @@ public extension OpenGroupAPI.PreparedSendData { let validResponse: R = try responseConverter(info, response) return try transform(info, validResponse) - } + }, + method: method, + endpoint: endpoint, + path: path, + batchEndpoints: batchEndpoints, + batchResponseTypes: batchResponseTypes, + jsonBodyEncoder: jsonBodyEncoder, + b64: b64, + bytes: bytes ) } } @@ -90,6 +192,22 @@ public extension Publisher where Output == (ResponseInfoType, Data?), Failure == // Depending on the 'originalType' we need to process the response differently let targetData: Any = try { switch preparedData.originalType { + case is OpenGroupAPI.BatchResponse.Type: + let responses: [Decodable] = try HTTP.BatchResponse.decodingResponses( + from: maybeData, + as: preparedData.batchResponseTypes, + requireAllResults: true, + using: dependencies + ) + + return OpenGroupAPI.BatchResponse( + info: responseInfo, + data: Swift.zip(preparedData.batchEndpoints, responses) + .reduce(into: [:]) { result, next in + result[next.0] = next.1 + } + ) + case is NoResponse.Type: return NoResponse() case is Optional.Type: return maybeData as Any case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }() diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 975a31770..873319035 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit extension OpenGroupAPI { public final class Poller { - typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Codable]) + typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Decodable]) private let server: String private var timer: Timer? = nil @@ -122,7 +122,7 @@ extension OpenGroupAPI { let server: String = self.server return dependencies.storage - .readPublisherFlatMap { db -> AnyPublisher<(Int64, PollResponse), Error> in + .readPublisher { db -> (Int64, PreparedSendData) in let failureCount: Int64 = (try? OpenGroup .filter(OpenGroup.Columns.server == server) .select(max(OpenGroup.Columns.pollFailureCount)) @@ -130,22 +130,27 @@ extension OpenGroupAPI { .fetchOne(db)) .defaulting(to: 0) - return OpenGroupAPI - .poll( - db, - server: server, - hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, - timeSinceLastPoll: ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.cache.getTimeSinceLastOpen(using: dependencies) - ), - using: dependencies - ) - .map { response in (failureCount, response) } - .eraseToAnyPublisher() + return ( + failureCount, + try OpenGroupAPI + .preparedPoll( + db, + server: server, + hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, + timeSinceLastPoll: ( + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.cache.getTimeSinceLastOpen(using: dependencies) + ), + using: dependencies + ) + ) + } + .flatMap { failureCount, sendData in + OpenGroupAPI.send(data: sendData, using: dependencies) + .map { info, response in (failureCount, info, response) } } .handleEvents( - receiveOutput: { [weak self] failureCount, response in + receiveOutput: { [weak self] failureCount, info, response in guard !calledFromBackgroundPoller || isBackgroundPollerValid() else { // If this was a background poll and the background poll is no longer valid // then just stop @@ -155,7 +160,8 @@ extension OpenGroupAPI { self?.isPolling = false self?.handlePollResponse( - response, + info: info, + response: response, failureCount: failureCount, using: dependencies ) @@ -363,12 +369,13 @@ extension OpenGroupAPI { } private func handlePollResponse( - _ response: PollResponse, + info: ResponseInfoType, + response: BatchResponse, failureCount: Int64, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() ) { let server: String = self.server - let validResponses: [OpenGroupAPI.Endpoint: Codable] = response.data + let validResponses: [OpenGroupAPI.Endpoint: Decodable] = response.data .filter { endpoint, data in switch endpoint { case .capabilities: @@ -467,7 +474,7 @@ extension OpenGroupAPI { return (capabilities, groups) } - let changedResponses: [OpenGroupAPI.Endpoint: Codable] = validResponses + let changedResponses: [OpenGroupAPI.Endpoint: Decodable] = validResponses .filter { endpoint, data in switch endpoint { case .capabilities: diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index f3d07f91d..9487b7b68 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -287,6 +287,12 @@ internal extension SessionUtil { contact.approved_me = updatedContact.didApproveMe contact.blocked = updatedContact.isBlocked + // If we were given a `created` timestamp then set it to the min between the current + // setting and the value (as long as the current setting isn't `0`) + if let created: Int64 = info.created.map({ Int64(floor($0)) }) { + contact.created = (contact.created > 0 ? min(contact.created, created) : created) + } + // Store the updated contact (needs to happen before variables go out of scope) contacts_set(conf, &contact) } @@ -494,17 +500,20 @@ extension SessionUtil { let contact: Contact? let profile: Profile? let priority: Int32? + let created: TimeInterval? init( id: String, contact: Contact? = nil, profile: Profile? = nil, - priority: Int32? = nil + priority: Int32? = nil, + created: TimeInterval? = nil ) { self.id = id self.contact = contact self.profile = profile self.priority = priority + self.created = created } } } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index c11af914e..5fd4f3857 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -1111,8 +1111,8 @@ public extension SessionThreadViewModel { /// Step 1 - Keep any "quoted" sections as stand-alone search /// Step 2 - Separate any words outside of quotes /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) - /// Step 4 - Append a wild-card character to the final word - return searchTerm + /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) + return standardQuotes(searchTerm) .split(separator: "\"") .enumerated() .flatMap { index, value -> [String] in @@ -1127,6 +1127,13 @@ public extension SessionThreadViewModel { .filter { !$0.isEmpty } } + static func standardQuotes(_ term: String) -> String { + // Apple like to use the special '”“' quote characters when typing so replace them with normal ones + return term + .replacingOccurrences(of: "”", with: "\"") + .replacingOccurrences(of: "“", with: "\"") + } + static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern { return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) } @@ -1134,9 +1141,16 @@ public extension SessionThreadViewModel { static func pattern(_ db: Database, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to // add a prefix one - let rawPattern: String = searchTermParts(searchTerm) - .joined(separator: " OR ") - .appending("*") + let rawPattern: String = { + let result: String = searchTermParts(searchTerm) + .joined(separator: " OR ") + + // If the last character is a quotation mark then assume the user doesn't want to append + // a wildcard character + guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } + + return "\(result)*" + }() let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" /// There are cases where creating a pattern can fail, we want to try and recover from those cases diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift index c6ea98995..345b3044d 100644 --- a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -24,47 +24,11 @@ class BatchRequestInfoSpec: QuickSpec { describe("a BatchRequest.Child") { var request: OpenGroupAPI.BatchRequest! - context("when initializing") { - it("sets the headers to nil if there aren't any") { - request = OpenGroupAPI.BatchRequest( - requests: [ - OpenGroupAPI.BatchRequest.Info( - request: Request( - server: "testServer", - endpoint: .batch - ) - ) - ] - ) - - expect(request.requests.first?.headers).to(beNil()) - } - - it("converts the headers to HTTP headers") { - request = OpenGroupAPI.BatchRequest( - requests: [ - OpenGroupAPI.BatchRequest.Info( - request: Request( - method: .get, - server: "testServer", - endpoint: .batch, - queryParameters: [:], - headers: [.authorization: "testAuth"], - body: nil - ) - ) - ] - ) - - expect(request.requests.first?.headers).to(equal(["Authorization": "testAuth"])) - } - } - context("when encoding") { it("successfully encodes a string body") { request = OpenGroupAPI.BatchRequest( requests: [ - OpenGroupAPI.BatchRequest.Info( + OpenGroupAPI.PreparedSendData( request: Request( method: .get, server: "testServer", @@ -72,21 +36,25 @@ class BatchRequestInfoSpec: QuickSpec { queryParameters: [:], headers: [:], body: "testBody" - ) + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 ) ] ) - let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) - let childRequestString: String? = String(data: childRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(childRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}")) + expect(requestString) + .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}]")) } it("successfully encodes a byte body") { request = OpenGroupAPI.BatchRequest( requests: [ - OpenGroupAPI.BatchRequest.Info( + OpenGroupAPI.PreparedSendData( request: Request<[UInt8], OpenGroupAPI.Endpoint>( method: .get, server: "testServer", @@ -94,21 +62,25 @@ class BatchRequestInfoSpec: QuickSpec { queryParameters: [:], headers: [:], body: [1, 2, 3] - ) + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 ) ] ) - let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) - let childRequestString: String? = String(data: childRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(childRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}")) + expect(requestString) + .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}]")) } it("successfully encodes a JSON body") { request = OpenGroupAPI.BatchRequest( requests: [ - OpenGroupAPI.BatchRequest.Info( + OpenGroupAPI.PreparedSendData( request: Request( method: .get, server: "testServer", @@ -116,64 +88,93 @@ class BatchRequestInfoSpec: QuickSpec { queryParameters: [:], headers: [:], body: TestType(stringValue: "testValue") - ) + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 ) ] ) - let childRequestData: Data = try! JSONEncoder().encode(request.requests[0]) - let childRequestString: String? = String(data: childRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(childRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}")) + expect(requestString) + .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}]")) + } + + it("strips authentication headers") { + let httpRequest: Request = Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [ + "TestHeader": "Test", + HTTPHeader.sogsPubKey: "A", + HTTPHeader.sogsTimestamp: "B", + HTTPHeader.sogsNonce: "C", + HTTPHeader.sogsSignature: "D" + ], + body: nil + ) + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: httpRequest, + urlRequest: try! httpRequest.generateUrlRequest(), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] + ) + + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) + + expect(requestString) + .toNot(contain([ + HTTPHeader.sogsPubKey, + HTTPHeader.sogsTimestamp, + HTTPHeader.sogsNonce, + HTTPHeader.sogsSignature + ])) } } - } - - // MARK: - BatchRequest.Info - - describe("a BatchRequest.Info") { - var request: Request! - beforeEach { - request = Request( + it("does not strip non authentication headers") { + let httpRequest: Request = Request( method: .get, server: "testServer", endpoint: .batch, queryParameters: [:], - headers: [:], - body: TestType(stringValue: "testValue") + headers: [ + "TestHeader": "Test", + HTTPHeader.sogsPubKey: "A", + HTTPHeader.sogsTimestamp: "B", + HTTPHeader.sogsNonce: "C", + HTTPHeader.sogsSignature: "D" + ], + body: nil ) - } - - it("initializes correctly when given a request") { - let requestInfo: OpenGroupAPI.BatchRequest.Info = OpenGroupAPI.BatchRequest.Info( - request: request + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: httpRequest, + urlRequest: try! httpRequest.generateUrlRequest(), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] ) - expect(requestInfo.endpoint.path).to(equal(request.endpoint.path)) - expect(requestInfo.responseType == HTTP.BatchSubResponse.self).to(beTrue()) - } - - it("initializes correctly when given a request and a response type") { - let requestInfo: OpenGroupAPI.BatchRequest.Info = OpenGroupAPI.BatchRequest.Info( - request: request, - responseType: TestType.self - ) - - expect(requestInfo.endpoint.path).to(equal(request.endpoint.path)) - expect(requestInfo.responseType == HTTP.BatchSubResponse.self).to(beTrue()) - } - } - - // MARK: - Convenience - // MARK: --Decodable - - describe("a Decodable") { - it("decodes correctly") { - let jsonData: Data = "{\"stringValue\":\"testValue\"}".data(using: .utf8)! - let result: TestType? = try? TestType.decoded(from: jsonData) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(result).to(equal(TestType(stringValue: "testValue"))) + expect(requestString) + .to(contain("\"TestHeader\":\"Test\"")) } } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 0697d65a1..c74bf5c2c 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -28,7 +28,7 @@ class OpenGroupAPISpec: QuickSpec { var disposables: [AnyCancellable] = [] var response: (ResponseInfoType, Codable)? = nil - var pollResponse: (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Codable])? + var pollResponse: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? var error: Error? describe("an OpenGroupAPI") { @@ -186,8 +186,8 @@ class OpenGroupAPISpec: QuickSpec { it("generates the correct request") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -195,6 +195,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -221,8 +222,8 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -230,6 +231,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -250,8 +252,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -259,6 +261,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -279,8 +282,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -288,6 +291,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -308,8 +312,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -317,6 +321,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -340,8 +345,8 @@ class OpenGroupAPISpec: QuickSpec { it("does not call the inbox and outbox endpoints") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -349,6 +354,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -439,8 +445,8 @@ class OpenGroupAPISpec: QuickSpec { it("includes the inbox and outbox endpoints") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -448,6 +454,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -466,8 +473,8 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent inbox messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -475,6 +482,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -495,8 +503,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -504,6 +512,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -519,8 +528,8 @@ class OpenGroupAPISpec: QuickSpec { it("retrieves recent outbox messages if there was no last message") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -528,6 +537,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -548,8 +558,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -557,6 +567,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -609,8 +620,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -618,6 +629,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -639,8 +651,8 @@ class OpenGroupAPISpec: QuickSpec { it("errors when no data is returned") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -648,6 +660,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -668,8 +681,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -677,6 +690,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -697,8 +711,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -706,6 +720,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -726,8 +741,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -735,6 +750,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -787,8 +803,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -796,6 +812,7 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in pollResponse = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -985,17 +1002,18 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OpenGroupAPI.CapabilitiesAndRoomResponse? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.capabilitiesAndRoom( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, for: "testRoom", on: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1040,18 +1058,18 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OpenGroupAPI.CapabilitiesAndRoomResponse? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .capabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1112,18 +1130,18 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OpenGroupAPI.CapabilitiesAndRoomResponse? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .capabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -1201,17 +1219,18 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OpenGroupAPI.CapabilitiesAndRoomResponse? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI.capabilitiesAndRoom( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, for: "testRoom", on: "testserver", using: dependencies ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2809,7 +2828,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when banning and deleting all messages for a user") { - var response: (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: ResponseInfoType])? + var response: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2845,16 +2864,16 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) @@ -2874,16 +2893,16 @@ class OpenGroupAPISpec: QuickSpec { it("bans the user from the specified room rather than globally") { mockStorage - .readPublisherFlatMap { db in - OpenGroupAPI - .userBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) diff --git a/SessionSnodeKit/Models/SnodeBatchRequest.swift b/SessionSnodeKit/Models/SnodeBatchRequest.swift index 92c88ef4c..7824ca44b 100644 --- a/SessionSnodeKit/Models/SnodeBatchRequest.swift +++ b/SessionSnodeKit/Models/SnodeBatchRequest.swift @@ -14,7 +14,7 @@ internal extension SnodeAPI { // MARK: - BatchRequest.Info struct Info { - public let responseType: Codable.Type + public let responseType: Decodable.Type fileprivate let child: Child public init(request: SnodeRequest, responseType: R.Type) { diff --git a/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift b/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift index c3af90b73..3967e9eed 100644 --- a/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift +++ b/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift @@ -38,11 +38,11 @@ public extension AES.GCM { /// - Note: Sync. Don't call from the main thread. static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data { + #if DEBUG if Thread.isMainThread { - #if DEBUG preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") - #endif } + #endif guard let sharedSecret: Data = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else { throw Error.sharedSecretGenerationFailed } @@ -58,11 +58,11 @@ public extension AES.GCM { /// - Note: Sync. Don't call from the main thread. static func decrypt(_ nonceAndCiphertext: Data, with symmetricKey: Data) throws -> Data { + #if DEBUG if Thread.isMainThread { - #if DEBUG preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") - #endif } + #endif return try AES.GCM.open( try AES.GCM.SealedBox(combined: nonceAndCiphertext), @@ -72,11 +72,11 @@ public extension AES.GCM { /// - Note: Sync. Don't call from the main thread. static func encrypt(_ plaintext: Data, with symmetricKey: Data) throws -> Data { + #if DEBUG if Thread.isMainThread { - #if DEBUG preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") - #endif } + #endif let nonceData: Data = try Randomness.generateRandomBytes(numberBytes: ivSize) let sealedData: AES.GCM.SealedBox = try AES.GCM.seal( @@ -94,11 +94,11 @@ public extension AES.GCM { /// - Note: Sync. Don't call from the main thread. static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult { + #if DEBUG if Thread.isMainThread { - #if DEBUG preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") - #endif } + #endif let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey) let ephemeralKeyPair = Curve25519.generateKeyPair() let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey) diff --git a/SessionUtilitiesKit/Networking/BatchResponse.swift b/SessionUtilitiesKit/Networking/BatchResponse.swift index 4b0e244e8..600b82982 100644 --- a/SessionUtilitiesKit/Networking/BatchResponse.swift +++ b/SessionUtilitiesKit/Networking/BatchResponse.swift @@ -4,18 +4,63 @@ import Foundation import Combine public extension HTTP { - typealias BatchResponseTypes = [Codable.Type] - // MARK: - BatchResponse struct BatchResponse { public let info: ResponseInfoType - public let responses: [Codable] + public let responses: [Decodable] + + public static func decodingResponses( + from data: Data?, + as types: [Decodable.Type], + requireAllResults: Bool, + using dependencies: Dependencies = Dependencies() + ) throws -> [Decodable] { + // Need to split the data into an array of data so each item can be Decoded correctly + guard let data: Data = data else { throw HTTPError.parsingFailed } + guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + throw HTTPError.parsingFailed + } + + let dataArray: [Data] + + switch jsonObject { + case let anyArray as [Any]: + dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } + + guard !requireAllResults || dataArray.count == types.count else { + throw HTTPError.parsingFailed + } + + case let anyDict as [String: Any]: + guard + let resultsArray: [Data] = (anyDict["results"] as? [Any])? + .compactMap({ try? JSONSerialization.data(withJSONObject: $0) }), + ( + !requireAllResults || + resultsArray.count == types.count + ) + else { throw HTTPError.parsingFailed } + + dataArray = resultsArray + + default: throw HTTPError.parsingFailed + } + + return try zip(dataArray, types) + .map { data, type in try type.decoded(from: data, using: dependencies) } + } } // MARK: - BatchSubResponse - struct BatchSubResponse: BatchSubResponseType { + struct BatchSubResponse: BatchSubResponseType { + public enum CodingKeys: String, CodingKey { + case code + case headers + case body + } + /// The numeric http response code (e.g. 200 for success) public let code: Int @@ -42,7 +87,7 @@ public extension HTTP { } } -public protocol BatchSubResponseType: Codable { +public protocol BatchSubResponseType: Decodable { var code: Int { get } var headers: [String: String] { get } var failedToParseBody: Bool { get } @@ -52,6 +97,8 @@ extension BatchSubResponseType { public var responseInfo: ResponseInfoType { HTTP.ResponseInfo(code: code, headers: headers) } } +extension HTTP.BatchSubResponse: Encodable where T: Encodable {} + public extension HTTP.BatchSubResponse { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) @@ -80,48 +127,20 @@ public extension Decodable { public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { func decoded( - as types: HTTP.BatchResponseTypes, + as types: [Decodable.Type], requireAllResults: Bool = true, using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { self .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 { throw HTTPError.parsingFailed } - guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { - throw HTTPError.parsingFailed - } - - let dataArray: [Data] - - switch jsonObject { - case let anyArray as [Any]: - dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } - - guard !requireAllResults || dataArray.count == types.count else { - throw HTTPError.parsingFailed - } - - case let anyDict as [String: Any]: - guard - let resultsArray: [Data] = (anyDict["results"] as? [Any])? - .compactMap({ try? JSONSerialization.data(withJSONObject: $0) }), - ( - !requireAllResults || - resultsArray.count == types.count - ) - else { throw HTTPError.parsingFailed } - - dataArray = resultsArray - - default: throw HTTPError.parsingFailed - } - - // TODO: Remove the 'Swift.' - return HTTP.BatchResponse( + HTTP.BatchResponse( info: responseInfo, - responses: try Swift.zip(dataArray, types) - .map { data, type in try type.decoded(from: data, using: dependencies) } + responses: try HTTP.BatchResponse.decodingResponses( + from: maybeData, + as: types, + requireAllResults: requireAllResults, + using: dependencies + ) ) } .eraseToAnyPublisher() diff --git a/SessionUtilitiesKit/Utilities/Version.swift b/SessionUtilitiesKit/Utilities/Version.swift index 38daf00bf..dadc37c04 100644 --- a/SessionUtilitiesKit/Utilities/Version.swift +++ b/SessionUtilitiesKit/Utilities/Version.swift @@ -47,8 +47,8 @@ public struct Version: Comparable { } public static func < (lhs: Version, rhs: Version) -> Bool { - guard lhs.major >= rhs.major else { return true } - guard lhs.minor >= rhs.minor else { return true } + guard lhs.major == rhs.major else { return (lhs.major < rhs.major) } + guard lhs.minor == rhs.minor else { return (lhs.minor < rhs.minor) } return (lhs.patch < rhs.patch) } diff --git a/SessionUtilitiesKitTests/Utilities/VersionSpec.swift b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift index 2d46e2f3d..55c25ba4d 100644 --- a/SessionUtilitiesKitTests/Utilities/VersionSpec.swift +++ b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift @@ -54,11 +54,15 @@ class VersionSpec: QuickSpec { } it("returns correctly for a complex major difference") { - let version1: Version = Version.from("2.90.90") - let version2: Version = Version.from("10.0.0") + let version1a: Version = Version.from("2.90.90") + let version2a: Version = Version.from("10.0.0") + let version1b: Version = Version.from("0.7.2") + let version2b: Version = Version.from("5.0.2") - expect(version1 < version2).to(beTrue()) - expect(version2 > version1).to(beTrue()) + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) } it("returns correctly for a simple minor difference") { @@ -70,11 +74,15 @@ class VersionSpec: QuickSpec { } it("returns correctly for a complex minor difference") { - let version1: Version = Version.from("90.2.90") - let version2: Version = Version.from("90.10.0") + let version1a: Version = Version.from("90.2.90") + let version2a: Version = Version.from("90.10.0") + let version1b: Version = Version.from("2.0.7") + let version2b: Version = Version.from("2.5.0") - expect(version1 < version2).to(beTrue()) - expect(version2 > version1).to(beTrue()) + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) } it("returns correctly for a simple patch difference") { @@ -86,11 +94,15 @@ class VersionSpec: QuickSpec { } it("returns correctly for a complex patch difference") { - let version1: Version = Version.from("90.90.2") - let version2: Version = Version.from("90.90.10") + let version1a: Version = Version.from("90.90.2") + let version2a: Version = Version.from("90.90.10") + let version1b: Version = Version.from("2.5.0") + let version2b: Version = Version.from("2.5.7") - expect(version1 < version2).to(beTrue()) - expect(version2 > version1).to(beTrue()) + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) } } }