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
This commit is contained in:
Morgan Pretty 2023-06-26 18:03:40 +10:00
parent 53a5db0ea5
commit d8ae9669c8
21 changed files with 751 additions and 689 deletions

View File

@ -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)",

View File

@ -2088,21 +2088,20 @@ extension ConversationVC:
cancelStyle: .alert_text,
onConfirm: { [weak self] _ in
Storage.shared
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> 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(

View File

@ -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

View File

@ -193,6 +193,7 @@ extension SyncPushTokensJob {
return Fail(error: error)
.eraseToAnyPublisher()
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete(
receiveCompletion: { result in
switch result {

View File

@ -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)..<part.index(before: part.endIndex))
return String(part[partRange])
return part.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
.forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds results that start

View File

@ -111,7 +111,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
}
return Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0)
}()
}(),
created: allThreads[data.contact.id]?.creationDateTimestamp
)
},
in: conf

View File

@ -114,7 +114,7 @@ public enum ConfigurationSyncJob: JobExecutor {
/// in the same order, this means we can just `zip` the two arrays as it will take the smaller of the two and
/// correctly align the response to the change
zip(response.responses, pendingConfigChanges)
.compactMap { (subResponse: Codable, change: SessionUtil.OutgoingConfResult) in
.compactMap { (subResponse: Decodable, change: SessionUtil.OutgoingConfResult) in
/// If the request wasn't successful then just ignore it (the next time we sync this config we will try
/// to send the changes again)
guard

View File

@ -4,41 +4,22 @@ import Foundation
import Combine
import SessionUtilitiesKit
internal extension OpenGroupAPI {
struct BatchRequest: Encodable {
public extension OpenGroupAPI {
internal struct BatchRequest: Encodable {
let requests: [Child]
init(requests: [Info]) {
self.requests = requests.map { $0.child }
init(requests: [ErasedPreparedSendData]) {
self.requests = requests.map { Child(request: $0) }
}
// MARK: - Encodable
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(requests)
}
// MARK: - BatchRequest.Info
struct Info {
public let endpoint: any EndpointType
public let responseType: Codable.Type
fileprivate let child: Child
public init<T: Encodable, E: EndpointType, R: Codable>(request: Request<T, E>, responseType: R.Type) {
self.endpoint = request.endpoint
self.responseType = HTTP.BatchSubResponse<R>.self
self.child = Child(request: request)
}
public init<T: Encodable, E: EndpointType>(request: Request<T, E>) {
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>, CodingKeys) throws -> ())?
private let b64: String?
private let bytes: [UInt8]?
internal init<T: Encodable, E: EndpointType>(request: Request<T, E>) {
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<CodingKeys> = 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<E: EndpointType>(
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<Endpoint, Decodable>.Keys { data.keys }
public var values: Dictionary<Endpoint, Decodable>.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<OpenGroupAPI.BatchResponse>` support")
#else
data = [:]
#endif
}
}
}

View File

@ -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<BatchResponse> {
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<NoBody, Endpoint>(
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<NoBody, Endpoint>(
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<NoBody, Endpoint>(
server: server,
endpoint: (shouldRetrieveRecentMessages ?
.roomMessagesRecent(openGroup.roomToken) :
.roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber)
),
queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "5"
]
),
responseType: [Failable<Message>].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<NoBody, Endpoint>(
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<NoBody, Endpoint>(
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<BatchResponse> {
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<BatchResponse> {
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<CapabilitiesAndRoomResponse, 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<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
),
responseType: Capabilities.self
),
// And the room info
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .room(roomToken)
),
responseType: Room.self
)
]
return OpenGroupAPI
.sequence(
) throws -> PreparedSendData<CapabilitiesAndRoomResponse> {
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<Capabilities>? = (data[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
let maybeRoomResponse: Codable? = data
.map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomResponse in
let maybeCapabilities: HTTP.BatchSubResponse<Capabilities>? = (response[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
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<NoBody, Endpoint>(
server: server,
endpoint: .capabilities
),
responseType: Capabilities.self
),
// And the room info
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
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<Capabilities>? = (data[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
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<Capabilities>? = (response[.capabilities] as? HTTP.BatchSubResponse<Capabilities>)
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<Message>]> {
return try OpenGroupAPI
.prepareSendData(
db,
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .roomMessagesRecent(roomToken)
endpoint: .roomMessagesRecent(roomToken),
queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "5"
]
),
responseType: [Message].self,
responseType: [Failable<Message>].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<Message>]> {
return try OpenGroupAPI
.prepareSendData(
db,
request: Request<NoBody, Endpoint>(
server: server,
endpoint: .roomMessagesBefore(roomToken, id: messageId)
endpoint: .roomMessagesBefore(roomToken, id: messageId),
queryParameters: [
.updateTypes: UpdateTypes.reaction.rawValue,
.reactors: "5"
]
),
responseType: [Message].self,
responseType: [Failable<Message>].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<Message>]> {
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<Message>].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 /<reaction> 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<UserBanRequest, Endpoint>(
method: .post,
server: server,
endpoint: .userBan(sessionId),
body: banRequestBody
)
),
BatchRequest.Info(
request: Request<NoBody, Endpoint>(
method: .delete,
server: server,
endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId)
)
)
]
return OpenGroupAPI
.sequence(
) throws -> PreparedSendData<BatchResponse> {
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<R>` 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<T: Encodable, R: Decodable>(
_ db: Database,
request: Request<T, Endpoint>,
@ -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<T: Encodable>(
_ db: Database,
request: Request<T, Endpoint>,
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<R>` and actually sends it to the API
public static func send<R>(
data: PreparedSendData<R>?,
using dependencies: SMKDependencies = SMKDependencies()

View File

@ -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<Void, Error> in
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.flatMap { info, response -> Future<Void, Error> in
Future<Void, Error> { 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
}

View File

@ -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<R>
public extension OpenGroupAPI {
struct PreparedSendData<R> {
struct PreparedSendData<R>: 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>, BatchRequest.Child.CodingKeys) throws -> ())?
private let b64: String?
private let bytes: [UInt8]?
internal init<T: Encodable>(
request: Request<T, Endpoint>,
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<R>.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<U: Decodable>(
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>, 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<BatchRequest.Child.CodingKeys> = 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<O>(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData<O> {
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<Data>.Type: return maybeData as Any
case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }()

View File

@ -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<BatchResponse>) 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:

View File

@ -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
}
}
}

View File

@ -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<T>(_ 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

View File

@ -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<NoBody, OpenGroupAPI.Endpoint>(
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<NoBody, OpenGroupAPI.Endpoint>(
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<NoResponse>(
request: Request<String, OpenGroupAPI.Endpoint>(
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<NoResponse>(
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<NoResponse>(
request: Request<TestType, OpenGroupAPI.Endpoint>(
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<NoBody, OpenGroupAPI.Endpoint> = Request<NoBody, OpenGroupAPI.Endpoint>(
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<NoResponse>(
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<TestType, OpenGroupAPI.Endpoint>!
beforeEach {
request = Request(
it("does not strip non authentication headers") {
let httpRequest: Request<NoBody, OpenGroupAPI.Endpoint> = Request<NoBody, OpenGroupAPI.Endpoint>(
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<NoResponse>(
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<NoResponse>.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
)
let requestData: Data = try! JSONEncoder().encode(request)
let requestString: String? = String(data: requestData, encoding: .utf8)
expect(requestInfo.endpoint.path).to(equal(request.endpoint.path))
expect(requestInfo.responseType == HTTP.BatchSubResponse<TestType>.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)
expect(result).to(equal(TestType(stringValue: "testValue")))
expect(requestString)
.to(contain("\"TestHeader\":\"Test\""))
}
}
}

View File

@ -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)

View File

@ -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<T: Encodable, R: Codable>(request: SnodeRequest<T>, responseType: R.Type) {

View File

@ -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)

View File

@ -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<T>
struct BatchSubResponse<T: Codable>: BatchSubResponseType {
struct BatchSubResponse<T: Decodable>: 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<CodingKeys> = 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<HTTP.BatchResponse, Error> {
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()

View File

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

View File

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