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()) } } }