Merge branch 'feature/updated-user-config-handling' of https://github.com/mpretty-cyro/session-ios into disappearing-message-redesign

pull/941/head
ryanzhao 11 months ago
commit ee5de25d4a

@ -6459,7 +6459,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)";
@ -6531,7 +6531,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;
@ -6596,7 +6596,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)";
@ -6670,7 +6670,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;
@ -7578,7 +7578,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)",
@ -7649,7 +7649,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)",

@ -2119,21 +2119,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(

@ -1681,10 +1681,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
@ -1897,17 +1906,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

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

@ -169,21 +169,22 @@ final class NukeDataModal: Modal {
Publishers
.MergeMany(
Storage.shared
.read { db -> [AnyPublisher<[String: Bool], Error>] in
try OpenGroup
.read { db -> [(String, OpenGroupAPI.PreparedSendData<DeleteInboxResponse>)] in
return try OpenGroup
.filter(OpenGroup.Columns.isActive == true)
.select(.server)
.distinct()
.asRequest(of: String.self)
.fetchSet(db)
.map { server -> AnyPublisher<[String: Bool], Error> in
OpenGroupAPI
.clearInbox(db, on: server)
.map { _, _ -> [String: Bool] in [server: true] }
.eraseToAnyPublisher()
}
.map { ($0, try OpenGroupAPI.preparedClearInbox(db, on: $0))}
}
.defaulting(to: [])
.compactMap { server, data in
OpenGroupAPI
.send(data: data)
.map { _ in [server: true] }
.eraseToAnyPublisher()
}
)
.collect()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
@ -200,7 +201,7 @@ final class NukeDataModal: Modal {
case .finished: break
case .failure(let error):
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(

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

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

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

@ -2,8 +2,6 @@
import Foundation
extension OpenGroupAPI {
public struct DeleteInboxResponse: Codable {
let deleted: UInt64
}
public struct DeleteInboxResponse: Codable {
let deleted: UInt64
}

@ -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,44 @@ 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
info = HTTP.ResponseInfo(code: 0, headers: [:])
data = [:]
#endif
}
}
}

@ -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,
@ -941,23 +908,22 @@ public enum OpenGroupAPI {
}
/// Remove all message requests from inbox, this methrod will return the number of messages deleted
public static func clearInbox(
public static func preparedClearInbox(
_ db: Database,
on server: String,
using dependencies: SMKDependencies = SMKDependencies()
) -> AnyPublisher<(ResponseInfoType, DeleteInboxResponse), Error> {
return OpenGroupAPI
.send(
) throws -> PreparedSendData<DeleteInboxResponse> {
return try OpenGroupAPI
.prepareSendData(
db,
request: Request<NoBody, Endpoint>(
method: .delete,
server: server,
endpoint: .inbox
),
responseType: DeleteInboxResponse.self,
using: dependencies
)
.decoded(as: DeleteInboxResponse.self, using: dependencies)
.eraseToAnyPublisher()
}
/// Delivers a direct message to a user via their blinded Session ID
@ -988,10 +954,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,
@ -1011,10 +974,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,
@ -1227,52 +1187,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
@ -1408,6 +1351,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>,
@ -1431,56 +1377,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()

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

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

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

@ -314,6 +314,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)
}
@ -620,19 +626,22 @@ extension SessionUtil {
let profile: Profile?
let config: DisappearingMessagesConfiguration?
let priority: Int32?
let created: TimeInterval?
init(
id: String,
contact: Contact? = nil,
profile: Profile? = nil,
disappearingMessagesConfig: DisappearingMessagesConfiguration? = nil,
priority: Int32? = nil
priority: Int32? = nil,
created: TimeInterval? = nil
) {
self.id = id
self.contact = contact
self.profile = profile
self.config = disappearingMessagesConfig
self.priority = priority
self.created = created
}
}
}

@ -1130,8 +1130,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
@ -1146,6 +1146,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)
}
@ -1153,9 +1160,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

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

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

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

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

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

@ -48,8 +48,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)
}

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

Loading…
Cancel
Save