Work on the PromiseKit refactor
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/ConversationVC+Interaction.swift # Session/Home/Message Requests/MessageRequestsViewModel.swift # Session/Notifications/AppNotifications.swift # Session/Notifications/PushRegistrationManager.swift # Session/Notifications/SyncPushTokensJob.swift # Session/Notifications/UserNotificationsAdaptee.swift # Session/Settings/BlockedContactsViewModel.swift # Session/Settings/NukeDataModal.swift # Session/Settings/SettingsViewModel.swift # Session/Utilities/BackgroundPoller.swift # SessionMessagingKit/Database/Models/ClosedGroup.swift # SessionMessagingKit/File Server/FileServerAPI.swift # SessionMessagingKit/Open Groups/OpenGroupAPI.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift # SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift # SessionMessagingKit/Sending & Receiving/MessageSender.swift # SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift # SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift # SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift # SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift # SessionMessagingKit/Utilities/ProfileManager.swift # SessionSnodeKit/Networking/SnodeAPI.swift # SessionSnodeKit/OnionRequestAPI.swift # SessionUtilitiesKit/Networking/HTTP.swift
This commit is contained in:
parent
f5933bdf75
commit
c9fdee9f24
16
Podfile.lock
16
Podfile.lock
|
@ -43,14 +43,14 @@ PODS:
|
|||
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||
- NVActivityIndicatorView/Base (5.1.1)
|
||||
- OpenSSL-Universal (1.1.1300)
|
||||
- PromiseKit (6.15.3):
|
||||
- PromiseKit/CorePromise (= 6.15.3)
|
||||
- PromiseKit/Foundation (= 6.15.3)
|
||||
- PromiseKit/UIKit (= 6.15.3)
|
||||
- PromiseKit/CorePromise (6.15.3)
|
||||
- PromiseKit/Foundation (6.15.3):
|
||||
- PromiseKit (6.18.1):
|
||||
- PromiseKit/CorePromise (= 6.18.1)
|
||||
- PromiseKit/Foundation (= 6.18.1)
|
||||
- PromiseKit/UIKit (= 6.18.1)
|
||||
- PromiseKit/CorePromise (6.18.1)
|
||||
- PromiseKit/Foundation (6.18.1):
|
||||
- PromiseKit/CorePromise
|
||||
- PromiseKit/UIKit (6.15.3):
|
||||
- PromiseKit/UIKit (6.18.1):
|
||||
- PromiseKit/CorePromise
|
||||
- PureLayout (3.1.9)
|
||||
- Quick (5.0.1)
|
||||
|
@ -227,7 +227,7 @@ SPEC CHECKSUMS:
|
|||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||
PromiseKit: 49d70c53d5d20e346beaea4b276b5dd2ab446bb4
|
||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
|
|
|
@ -1724,6 +1724,7 @@
|
|||
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
|
||||
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
|
||||
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = "<group>"; };
|
||||
FD2AAAF328EE882B00A49611 /* HTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPError.swift; sourceTree = "<group>"; };
|
||||
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||
FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = "<group>"; };
|
||||
FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = "<group>"; };
|
||||
|
|
|
@ -322,7 +322,9 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
}
|
||||
.done(on: DispatchQueue.main) { thread in
|
||||
Storage.shared.writeAsync { db in
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try? MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ContextMenuWindow : UIWindow {
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreServices
|
||||
import Photos
|
||||
import PhotosUI
|
||||
import PromiseKit
|
||||
import Sodium
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
@ -1160,7 +1161,7 @@ extension ConversationVC:
|
|||
guard cellViewModel.threadVariant == .openGroup else { return }
|
||||
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
.readPublisherFlatMap { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup
|
||||
.fetchOne(db, id: cellViewModel.threadId),
|
||||
|
@ -1170,10 +1171,11 @@ extension ConversationVC:
|
|||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
return Fail(error: StorageError.objectNotFound)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let pendingChange = OpenGroupManager
|
||||
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
|
@ -1190,23 +1192,28 @@ extension ConversationVC:
|
|||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
.map { _, response in (response, pendingChange) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.done { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.deleteAll(db)
|
||||
.handleEvents(
|
||||
receiveOutput: { response, pendingChange in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) {
|
||||
|
@ -1242,12 +1249,14 @@ extension ConversationVC:
|
|||
.suffix(19))
|
||||
.appending(sentTimestamp)
|
||||
}
|
||||
|
||||
// TODO: Need to test emoji reacts for both open groups and one-to-one to make sure this isn't broken
|
||||
// Perform the sending logic
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
Storage.shared
|
||||
.writePublisherFlatMap { db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: cellViewModel.threadId) else {
|
||||
return
|
||||
return Just(nil)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Update the thread to be visible
|
||||
|
@ -1296,92 +1305,12 @@ extension ConversationVC:
|
|||
Emoji.addRecent(db, emoji: emoji)
|
||||
}
|
||||
|
||||
if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId),
|
||||
OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server)
|
||||
{
|
||||
// Send reaction to open groups
|
||||
guard
|
||||
let openGroupServerMessageId: Int64 = try? Interaction
|
||||
.select(.openGroupServerMessageId)
|
||||
.filter(id: cellViewModel.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else { return }
|
||||
|
||||
if remove {
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: .remove
|
||||
)
|
||||
OpenGroupAPI
|
||||
.reactionDelete(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
else {
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: .add
|
||||
)
|
||||
|
||||
OpenGroupAPI
|
||||
.reactionAdd(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
// If it's not an OpenGroup then send the message directly to the thread
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId),
|
||||
OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server)
|
||||
else {
|
||||
// Send the actual message
|
||||
try MessageSender.send(
|
||||
let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: VisibleMessage(
|
||||
sentTimestamp: UInt64(sentTimestamp),
|
||||
|
@ -1399,12 +1328,100 @@ extension ConversationVC:
|
|||
kind: (remove ? .remove : .react)
|
||||
)
|
||||
),
|
||||
interactionId: cellViewModel.id,
|
||||
in: thread
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: cellViewModel.id
|
||||
)
|
||||
|
||||
return Just(sendData)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Otherwise we need to make an API call to the OpenGroup
|
||||
// Send reaction to open groups
|
||||
guard
|
||||
let openGroupServerMessageId: Int64 = try? Interaction
|
||||
.select(.openGroupServerMessageId)
|
||||
.filter(id: cellViewModel.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else {
|
||||
return Fail(error: MessageSenderError.invalidMessage)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: (remove ? .remove : .add)
|
||||
)
|
||||
|
||||
let request: AnyPublisher<Int64?, Error> = {
|
||||
switch remove {
|
||||
case true:
|
||||
return OpenGroupAPI
|
||||
.reactionDelete(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case false:
|
||||
return OpenGroupAPI
|
||||
.reactionAdd(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in response.seqNo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}()
|
||||
|
||||
return request
|
||||
.handleEvents(
|
||||
receiveOutput: { seqNo in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: seqNo
|
||||
)
|
||||
},
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.map { _ in nil }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
)
|
||||
.flatMap { maybeSendData -> AnyPublisher<Void, Error> in
|
||||
guard let sendData: MessageSender.PreparedSendData = maybeSendData else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return MessageSender.sendImmediate(data: sendData)
|
||||
}
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) {
|
||||
|
@ -1520,7 +1537,7 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
.writePublisherFlatMap { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
|
@ -1529,24 +1546,31 @@ extension ConversationVC:
|
|||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
let errorModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "COMMUNITY_ERROR_GENERIC".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
||||
presentingViewController.present(errorModal, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let errorModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "COMMUNITY_ERROR_GENERIC".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
||||
presentingViewController.present(errorModal, animated: true, completion: nil)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -1640,34 +1664,38 @@ extension ConversationVC:
|
|||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
// Remote deletion logic
|
||||
func deleteRemotely(from viewController: UIViewController?, request: Promise<Void>, onComplete: (() -> ())?) {
|
||||
func deleteRemotely(from viewController: UIViewController?, request: AnyPublisher<Void, Error>, onComplete: (() -> ())?) {
|
||||
// TODO: Test that this works
|
||||
// Show a loading indicator
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in
|
||||
seal.fulfill(())
|
||||
Future<Void, Error> { resolver in
|
||||
ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in
|
||||
// TODO: Remove the 'Swift.'
|
||||
resolver(Swift.Result.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
promise
|
||||
.then { _ -> Promise<Void> in request }
|
||||
.done { _ in
|
||||
// Delete the interaction (and associated data) from the database
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
.flatMap { _ in request }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished:
|
||||
// Delete the interaction (and associated data) from the database
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ensure {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.presentedViewController is ModalActivityIndicatorViewController {
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
}
|
||||
|
||||
onComplete?()
|
||||
|
||||
// Regardless of success we should dismiss and callback
|
||||
if self?.presentedViewController is ModalActivityIndicatorViewController {
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
}
|
||||
|
||||
onComplete?()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
|
||||
// How we delete the message differs depending on the type of thread
|
||||
|
@ -1752,7 +1780,7 @@ extension ConversationVC:
|
|||
// Delete the message from the open group
|
||||
deleteRemotely(
|
||||
from: self,
|
||||
request: Storage.shared.read { db in
|
||||
request: Storage.shared.readPublisherFlatMap { db in
|
||||
OpenGroupAPI.messageDelete(
|
||||
db,
|
||||
id: openGroupServerMessageId,
|
||||
|
@ -1760,6 +1788,7 @@ extension ConversationVC:
|
|||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
) { [weak self] in
|
||||
self?.showInputAccessoryView()
|
||||
|
@ -1838,6 +1867,7 @@ extension ConversationVC:
|
|||
serverHashes: [serverHash]
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
) { [weak self] in
|
||||
Storage.shared.writeAsync { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
||||
|
@ -1939,9 +1969,10 @@ extension ConversationVC:
|
|||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
return Fail(error: StorageError.objectNotFound)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -1952,20 +1983,27 @@ extension ConversationVC:
|
|||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
},
|
||||
|
@ -1988,9 +2026,10 @@ extension ConversationVC:
|
|||
cancelStyle: .alert_text,
|
||||
onConfirm: { [weak self] _ in
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
return Fail(error: StorageError.objectNotFound)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -2001,20 +2040,27 @@ extension ConversationVC:
|
|||
on: openGroup.server
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { _ in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "context_menu_ban_user_error_alert_message".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self?.becomeFirstResponder()
|
||||
},
|
||||
|
@ -2215,6 +2261,21 @@ extension ConversationVC {
|
|||
timestampMs: Int64
|
||||
) {
|
||||
guard threadVariant == .contact else { return }
|
||||
|
||||
let updateNavigationBackStack: () -> Void = {
|
||||
// Remove the 'MessageRequestsViewController' from the nav hierarchy if present
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if
|
||||
let viewControllers: [UIViewController] = self?.navigationController?.viewControllers,
|
||||
let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }),
|
||||
messageRequestsIndex > 0
|
||||
{
|
||||
var newViewControllers = viewControllers
|
||||
newViewControllers.remove(at: messageRequestsIndex)
|
||||
self?.navigationController?.viewControllers = newViewControllers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the contact doesn't exist then we should create it so we can store the 'isApproved' state
|
||||
// (it'll be updated with correct profile info if they accept the message request so this
|
||||
|
@ -2262,20 +2323,7 @@ extension ConversationVC {
|
|||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
},
|
||||
completion: { _, _ in
|
||||
// Remove the 'MessageRequestsViewController' from the nav hierarchy if present
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if
|
||||
let viewControllers: [UIViewController] = self?.navigationController?.viewControllers,
|
||||
let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }),
|
||||
messageRequestsIndex > 0
|
||||
{
|
||||
var newViewControllers = viewControllers
|
||||
newViewControllers.remove(at: messageRequestsIndex)
|
||||
self?.navigationController?.viewControllers = newViewControllers
|
||||
}
|
||||
}
|
||||
}
|
||||
completion: { _, _ in updateNavigationBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -485,7 +485,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -658,7 +658,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.save(db)
|
||||
},
|
||||
completion: { [weak self] db, _ in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
|
|
|
@ -721,7 +721,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
)
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -305,9 +305,9 @@ public class HomeViewModel {
|
|||
Storage.shared.writeAsync { db in
|
||||
switch threadVariant {
|
||||
case .closedGroup:
|
||||
try MessageSender
|
||||
MessageSender
|
||||
.leave(db, groupPublicKey: threadId)
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete()
|
||||
|
||||
case .openGroup:
|
||||
OpenGroupManager.shared.delete(db, openGroupId: threadId)
|
||||
|
|
|
@ -396,25 +396,40 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
switch section.model {
|
||||
case .threads:
|
||||
let threadId: String = section.elements[indexPath.row].threadId
|
||||
let threadVariant: SessionThread.Variant = section.elements[indexPath.row].threadVariant
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.delete(threadId)
|
||||
MessageRequestsViewModel.deleteMessageRequest(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
viewController: self
|
||||
)
|
||||
completionHandler(true)
|
||||
}
|
||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
let block: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.block(threadId)
|
||||
completionHandler(true)
|
||||
switch threadVariant {
|
||||
case .contact:
|
||||
let block: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
MessageRequestsViewModel.blockMessageRequest(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
viewController: self
|
||||
)
|
||||
completionHandler(true)
|
||||
}
|
||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ delete, block ])
|
||||
|
||||
case .closedGroup, .openGroup:
|
||||
return UISwipeActionsConfiguration(actions: [ delete ])
|
||||
}
|
||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ delete, block ])
|
||||
|
||||
default: return nil
|
||||
}
|
||||
|
@ -427,9 +442,16 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
return
|
||||
}
|
||||
|
||||
let threadIds: [String] = (viewModel.threadData
|
||||
let contactThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .contact }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let closedGroupThreadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.filter { $0.threadVariant == .closedGroup }
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
|
@ -444,62 +466,16 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
// Clear the requests
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(ids: threadIds)
|
||||
.filter(ids: contactThreadIds)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func delete(_ threadId: String) {
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func block(_ threadId: String) {
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try ClosedGroup.removeKeysAndUnsubscribe(
|
||||
db,
|
||||
threadIds: closedGroupThreadIds,
|
||||
removeGroupData: true
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
|
|
@ -165,4 +165,96 @@ public class MessageRequestsViewModel {
|
|||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
static func deleteMessageRequest(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
viewController: UIViewController?,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
||||
confirmTitle: "TXT_DELETE_TITLE".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
switch threadVariant {
|
||||
case .contact, .openGroup:
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
|
||||
case .closedGroup:
|
||||
try ClosedGroup.removeKeysAndUnsubscribe(
|
||||
db,
|
||||
threadId: threadId,
|
||||
removeGroupData: true
|
||||
)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
completion?()
|
||||
}
|
||||
)
|
||||
|
||||
viewController?.present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
static func blockMessageRequest(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
viewController: UIViewController?,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
guard threadVariant == .contact else { return }
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
||||
confirmTitle: "BLOCK_LIST_BLOCK_BUTTON".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
) { _ in
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
// Update the contact
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true,
|
||||
|
||||
// Note: We set this to true so the current user will be able to send a
|
||||
// message to the person who originally sent them the message request in
|
||||
// the future if they unblock them
|
||||
didApproveMe: true
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Remove the thread
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete()
|
||||
},
|
||||
completion: { _, _ in completion?() }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
viewController?.present(modal, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -183,45 +183,45 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
ModalActivityIndicatorViewController
|
||||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||
"DM_ERROR_INVALID".localized()
|
||||
)
|
||||
}()
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||
"DM_ERROR_INVALID".localized()
|
||||
)
|
||||
}()
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -298,7 +298,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
appVersion.lastAppVersion != appVersion.currentAppVersion
|
||||
)
|
||||
{
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -658,15 +660,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) }
|
||||
.done {
|
||||
// Only update the 'lastConfigurationSync' timestamp if we have done the
|
||||
// first sync (Don't want a new device config sync to override config
|
||||
// syncs from other devices)
|
||||
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
|
||||
UserDefaults.standard[.lastConfigurationSync] = Date()
|
||||
.writePublisherFlatMap { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) }
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished:
|
||||
// Only update the 'lastConfigurationSync' timestamp if we have done the
|
||||
// first sync (Don't want a new device config sync to override config
|
||||
// syncs from other devices)
|
||||
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
|
||||
UserDefaults.standard[.lastConfigurationSync] = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
@ -88,7 +88,7 @@ let kAudioNotificationsThrottleInterval: TimeInterval = 5
|
|||
|
||||
protocol NotificationPresenterAdaptee: AnyObject {
|
||||
|
||||
func registerNotificationSettings() -> Promise<Void>
|
||||
func registerNotificationSettings() -> Future<Void, Never>
|
||||
|
||||
func notify(
|
||||
category: AppNotificationCategory,
|
||||
|
@ -148,8 +148,9 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
|
||||
// MARK: - Presenting Notifications
|
||||
|
||||
func registerNotificationSettings() -> Promise<Void> {
|
||||
func registerNotificationSettings() -> AnyPublisher<Void, Never> {
|
||||
return adaptee.registerNotificationSettings()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) {
|
||||
|
@ -504,73 +505,84 @@ class NotificationActionHandler {
|
|||
|
||||
// MARK: -
|
||||
|
||||
func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
||||
func markAsRead(userInfo: [AnyHashable: Any]) -> AnyPublisher<Void, Error> {
|
||||
guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
||||
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
||||
return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
|
||||
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
||||
return Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return markAsRead(thread: thread)
|
||||
}
|
||||
|
||||
func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise<Void> {
|
||||
func reply(userInfo: [AnyHashable: Any], replyText: String) -> AnyPublisher<Void, Error> {
|
||||
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
||||
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
||||
return Fail<Void, Error>(error: NotificationError.failDebug("threadId was unexpectedly nil"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
|
||||
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
||||
return Fail<Void, Error>(error: NotificationError.failDebug("unable to find thread with id: \(threadId)"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: replyText,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText),
|
||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
).inserted(db)
|
||||
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: interaction.id,
|
||||
threadId: thread.id,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: true
|
||||
)
|
||||
|
||||
return try MessageSender.sendNonDurably(
|
||||
db,
|
||||
interaction: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
.done { seal.fulfill(()) }
|
||||
.catch { error in
|
||||
Storage.shared.read { [weak self] db in
|
||||
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
|
||||
return Storage.shared
|
||||
.writePublisher { db in
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: replyText,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
hasMention: Interaction.isUserMentioned(
|
||||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: thread.variant,
|
||||
body: replyText
|
||||
),
|
||||
expiresInSeconds: try? DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
).inserted(db)
|
||||
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: interaction.id,
|
||||
threadId: thread.id,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: true
|
||||
)
|
||||
// TODO: Will need to split the attachment upload from the message preparation logic
|
||||
return try MessageSender.preparedSendData(
|
||||
db,
|
||||
interaction: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
|
||||
seal.reject(error)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
return promise
|
||||
.flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) }
|
||||
.flatMap { MessageSender.sendImmediate(data: $0) }
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
Storage.shared.read { [weak self] db in
|
||||
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
||||
func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher<Void, Never> {
|
||||
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
||||
return showHomeVC()
|
||||
}
|
||||
|
@ -580,19 +592,19 @@ class NotificationActionHandler {
|
|||
// it animate in from the homescreen.
|
||||
let shouldAnimate: Bool = (UIApplication.shared.applicationState == .active)
|
||||
SessionApp.presentConversation(for: threadId, animated: shouldAnimate)
|
||||
return Promise.value(())
|
||||
return Just(())
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func showHomeVC() -> Promise<Void> {
|
||||
func showHomeVC() -> AnyPublisher<Void, Never> {
|
||||
SessionApp.showHomeView()
|
||||
return Promise.value(())
|
||||
return Just(())
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func markAsRead(thread: SessionThread) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
|
||||
private func markAsRead(thread: SessionThread) -> AnyPublisher<Void, Error> {
|
||||
return Storage.shared
|
||||
.writePublisher { db in
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: try thread.interactions
|
||||
|
@ -604,16 +616,8 @@ class NotificationActionHandler {
|
|||
includingOlder: true,
|
||||
trySendReadReceipt: true
|
||||
)
|
||||
},
|
||||
completion: { _, result in
|
||||
switch result {
|
||||
case .success: seal.fulfill(())
|
||||
case .failure(let error): seal.reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return promise
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import PushKit
|
||||
import SignalUtilitiesKit
|
||||
import GRDB
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public enum PushRegistrationError: Error {
|
||||
case assertionError(description: String)
|
||||
case pushNotSupported(description: String)
|
||||
case timeout
|
||||
case publisherNoLongerExists
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -41,64 +39,68 @@ public enum PushRegistrationError: Error {
|
|||
SwiftSingletons.register(self)
|
||||
}
|
||||
|
||||
private var vanillaTokenPromise: Promise<Data>?
|
||||
private var vanillaTokenResolver: Resolver<Data>?
|
||||
private var vanillaTokenPublisher: AnyPublisher<Data, Error>?
|
||||
private var vanillaTokenResolver: ((Result<Data, Error>) -> ())?
|
||||
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var voipTokenPromise: Promise<Data?>?
|
||||
private var voipTokenResolver: Resolver<Data?>?
|
||||
private var voipTokenPublisher: AnyPublisher<Data?, Error>?
|
||||
private var voipTokenResolver: ((Result<Data?, Error>) -> ())?
|
||||
|
||||
// MARK: Public interface
|
||||
// MARK: - Public interface
|
||||
|
||||
public func requestPushTokens() -> Promise<(pushToken: String, voipToken: String)> {
|
||||
public func requestPushTokens() -> AnyPublisher<(pushToken: String, voipToken: String), Error> {
|
||||
Logger.info("")
|
||||
|
||||
return registerUserNotificationSettings()
|
||||
.setFailureType(to: Error.self)
|
||||
.flatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in
|
||||
#if targetEnvironment(simulator)
|
||||
return Fail(error: PushRegistrationError.pushNotSupported(description: "Push not supported on simulators"))
|
||||
.eraseToAnyPublisher()
|
||||
#endif
|
||||
|
||||
return firstly { () -> Promise<Void> in
|
||||
self.registerUserNotificationSettings()
|
||||
}.then { (_) -> Promise<(pushToken: String, voipToken: String)> in
|
||||
#if targetEnvironment(simulator)
|
||||
throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators")
|
||||
#endif
|
||||
|
||||
return self.registerForVanillaPushToken().then { vanillaPushToken -> Promise<(pushToken: String, voipToken: String)> in
|
||||
self.registerForVoipPushToken().map { voipPushToken in
|
||||
(pushToken: vanillaPushToken, voipToken: voipPushToken ?? "")
|
||||
}
|
||||
return self.registerForVanillaPushToken()
|
||||
.flatMap { vanillaPushToken -> AnyPublisher<(pushToken: String, voipToken: String), Error> in
|
||||
self.registerForVoipPushToken()
|
||||
.map { voipPushToken in (vanillaPushToken, (voipPushToken ?? "")) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: Vanilla push token
|
||||
|
||||
// Vanilla push token is obtained from the system via AppDelegate
|
||||
@objc
|
||||
public func didReceiveVanillaPushToken(_ tokenData: Data) {
|
||||
guard let vanillaTokenResolver = self.vanillaTokenResolver else {
|
||||
owsFailDebug("promise completion in \(#function) unexpectedly nil")
|
||||
owsFailDebug("publisher completion in \(#function) unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
vanillaTokenResolver.fulfill(tokenData)
|
||||
vanillaTokenResolver(Result.success(tokenData))
|
||||
}
|
||||
|
||||
// Vanilla push token is obtained from the system via AppDelegate
|
||||
@objc
|
||||
public func didFailToReceiveVanillaPushToken(error: Error) {
|
||||
guard let vanillaTokenResolver = self.vanillaTokenResolver else {
|
||||
owsFailDebug("promise completion in \(#function) unexpectedly nil")
|
||||
owsFailDebug("publisher completion in \(#function) unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
vanillaTokenResolver.reject(error)
|
||||
vanillaTokenResolver(Result.failure(error))
|
||||
}
|
||||
|
||||
// MARK: helpers
|
||||
|
||||
// User notification settings must be registered *before* AppDelegate will
|
||||
// return any requested push tokens.
|
||||
public func registerUserNotificationSettings() -> Promise<Void> {
|
||||
public func registerUserNotificationSettings() -> AnyPublisher<Void, Never> {
|
||||
AssertIsOnMainThread()
|
||||
return notificationPresenter.registerNotificationSettings()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -126,52 +128,72 @@ public enum PushRegistrationError: Error {
|
|||
return true
|
||||
}
|
||||
|
||||
private func registerForVanillaPushToken() -> Promise<String> {
|
||||
private func registerForVanillaPushToken() -> AnyPublisher<String, Error> {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard self.vanillaTokenPromise == nil else {
|
||||
let promise = vanillaTokenPromise!
|
||||
assert(promise.isPending)
|
||||
return promise.map { $0.hexEncodedString }
|
||||
|
||||
// Use the existing publisher if it exists
|
||||
if let vanillaTokenPublisher: AnyPublisher<Data, Error> = self.vanillaTokenPublisher {
|
||||
return vanillaTokenPublisher
|
||||
.map { $0.toHexString() }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// No pending vanilla token yet; create a new promise
|
||||
let (promise, resolver) = Promise<Data>.pending()
|
||||
self.vanillaTokenPromise = promise
|
||||
self.vanillaTokenResolver = resolver
|
||||
|
||||
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
|
||||
let kTimeout: TimeInterval = 10
|
||||
let timeout: Promise<Data> = after(seconds: kTimeout).map { throw PushRegistrationError.timeout }
|
||||
let promiseWithTimeout: Promise<Data> = race(promise, timeout)
|
||||
|
||||
return promiseWithTimeout.recover { error -> Promise<Data> in
|
||||
switch error {
|
||||
case PushRegistrationError.timeout:
|
||||
if self.isSusceptibleToFailedPushRegistration {
|
||||
// If we've timed out on a device known to be susceptible to failures, quit trying
|
||||
// so the user doesn't remain indefinitely hung for no good reason.
|
||||
throw PushRegistrationError.pushNotSupported(description: "Device configuration disallows push notifications")
|
||||
} else {
|
||||
// Sometimes registration can just take a while.
|
||||
// If we're not on a device known to be susceptible to push registration failure,
|
||||
// just return the original promise.
|
||||
return promise
|
||||
|
||||
// No pending vanilla token yet; create a new publisher
|
||||
let publisher: AnyPublisher<Data, Error> = Future<Data, Error> { self.vanillaTokenResolver = $0 }
|
||||
.eraseToAnyPublisher()
|
||||
self.vanillaTokenPublisher = publisher
|
||||
|
||||
return publisher
|
||||
.timeout(
|
||||
.seconds(10),
|
||||
scheduler: DispatchQueue.main,
|
||||
customError: { PushRegistrationError.timeout }
|
||||
)
|
||||
.catch { error -> AnyPublisher<Data, Error> in
|
||||
switch error {
|
||||
case PushRegistrationError.timeout:
|
||||
guard self.isSusceptibleToFailedPushRegistration else {
|
||||
// Sometimes registration can just take a while.
|
||||
// If we're not on a device known to be susceptible to push registration failure,
|
||||
// just return the original publisher.
|
||||
guard let originalPublisher: AnyPublisher<Data, Error> = self.vanillaTokenPublisher else {
|
||||
return Fail(error: PushRegistrationError.publisherNoLongerExists)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return originalPublisher
|
||||
}
|
||||
|
||||
// If we've timed out on a device known to be susceptible to failures, quit trying
|
||||
// so the user doesn't remain indefinitely hung for no good reason.
|
||||
return Fail(
|
||||
error: PushRegistrationError.pushNotSupported(
|
||||
description: "Device configuration disallows push notifications"
|
||||
)
|
||||
).eraseToAnyPublisher()
|
||||
|
||||
default:
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
default:
|
||||
throw error
|
||||
}
|
||||
}.map { (pushTokenData: Data) -> String in
|
||||
if self.isSusceptibleToFailedPushRegistration {
|
||||
// Sentinal in case this bug is fixed
|
||||
OWSLogger.debug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.")
|
||||
.map { tokenData -> String in
|
||||
if self.isSusceptibleToFailedPushRegistration {
|
||||
// Sentinal in case this bug is fixed
|
||||
OWSLogger.debug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.")
|
||||
}
|
||||
|
||||
return tokenData.toHexString()
|
||||
}
|
||||
|
||||
return pushTokenData.hexEncodedString
|
||||
}.ensure {
|
||||
self.vanillaTokenPromise = nil
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { _ in
|
||||
self.vanillaTokenPublisher = nil
|
||||
self.vanillaTokenResolver = nil
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func createVoipRegistryIfNecessary() {
|
||||
|
@ -179,61 +201,68 @@ public enum PushRegistrationError: Error {
|
|||
|
||||
guard voipRegistry == nil else { return }
|
||||
let voipRegistry = PKPushRegistry(queue: nil)
|
||||
self.voipRegistry = voipRegistry
|
||||
self.voipRegistry = voipRegistry
|
||||
voipRegistry.desiredPushTypes = [.voIP]
|
||||
voipRegistry.delegate = self
|
||||
}
|
||||
|
||||
private func registerForVoipPushToken() -> Promise<String?> {
|
||||
private func registerForVoipPushToken() -> AnyPublisher<String?, Error> {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard self.voipTokenPromise == nil else {
|
||||
let promise = self.voipTokenPromise!
|
||||
return promise.map { $0?.hexEncodedString }
|
||||
|
||||
// Use the existing publisher if it exists
|
||||
if let voipTokenPublisher: AnyPublisher<Data?, Error> = self.voipTokenPublisher {
|
||||
return voipTokenPublisher
|
||||
.map { $0?.toHexString() }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// No pending voip token yet. Create a new promise
|
||||
let (promise, resolver) = Promise<Data?>.pending()
|
||||
self.voipTokenPromise = promise
|
||||
self.voipTokenResolver = resolver
|
||||
|
||||
|
||||
// We don't create the voip registry in init, because it immediately requests the voip token,
|
||||
// potentially before we're ready to handle it.
|
||||
createVoipRegistryIfNecessary()
|
||||
|
||||
guard let voipRegistry = self.voipRegistry else {
|
||||
|
||||
guard let voipRegistry: PKPushRegistry = self.voipRegistry else {
|
||||
owsFailDebug("failed to initialize voipRegistry")
|
||||
resolver.reject(PushRegistrationError.assertionError(description: "failed to initialize voipRegistry"))
|
||||
return promise.map { _ in
|
||||
// coerce expected type of returned promise - we don't really care about the value,
|
||||
// since this promise has been rejected. In practice this shouldn't happen
|
||||
String()
|
||||
}
|
||||
return Fail(
|
||||
error: PushRegistrationError.assertionError(description: "failed to initialize voipRegistry")
|
||||
).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
// If we've already completed registering for a voip token, resolve it immediately,
|
||||
// rather than waiting for the delegate method to be called.
|
||||
if let voipTokenData = voipRegistry.pushToken(for: .voIP) {
|
||||
if let voipTokenData: Data = voipRegistry.pushToken(for: .voIP) {
|
||||
Logger.info("using pre-registered voIP token")
|
||||
resolver.fulfill(voipTokenData)
|
||||
}
|
||||
|
||||
return promise.map { (voipTokenData: Data?) -> String? in
|
||||
Logger.info("successfully registered for voip push notifications")
|
||||
return voipTokenData?.hexEncodedString
|
||||
}.ensure {
|
||||
self.voipTokenPromise = nil
|
||||
return Just(voipTokenData.toHexString())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// No pending voip token yet. Create a new publisher
|
||||
let publisher: AnyPublisher<Data?, Error> = Future<Data?, Error> { self.voipTokenResolver = $0 }
|
||||
.eraseToAnyPublisher()
|
||||
self.voipTokenPublisher = publisher
|
||||
|
||||
return publisher
|
||||
.map { voipTokenData -> String? in
|
||||
Logger.info("successfully registered for voip push notifications")
|
||||
return voipTokenData?.toHexString()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { _ in
|
||||
self.voipTokenPublisher = nil
|
||||
self.voipTokenResolver = nil
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: PKPushRegistryDelegate
|
||||
// MARK: - PKPushRegistryDelegate
|
||||
|
||||
public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||
Logger.info("")
|
||||
owsAssertDebug(type == .voIP)
|
||||
owsAssertDebug(pushCredentials.type == .voIP)
|
||||
guard let voipTokenResolver = voipTokenResolver else { return }
|
||||
|
||||
voipTokenResolver.fulfill(pushCredentials.token)
|
||||
voipTokenResolver?(Result.success(pushCredentials.token))
|
||||
}
|
||||
|
||||
// NOTE: This function MUST report an incoming call.
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SignalCoreKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
@ -54,7 +55,8 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
let currentAppVersion: String? = AppVersion.sharedInstance().currentAppVersion
|
||||
|
||||
PushRegistrationManager.shared.requestPushTokens()
|
||||
.then(on: queue) { (pushToken: String, voipToken: String) -> Promise<Void> in
|
||||
.subscribe(on: queue)
|
||||
.flatMap { (pushToken: String, voipToken: String) -> AnyPublisher<Void, Error> in
|
||||
let lastPushToken: String? = Storage.shared[.lastRecordedPushToken]
|
||||
let lastVoipToken: String? = Storage.shared[.lastRecordedVoipToken]
|
||||
let shouldUploadTokens: Bool = (
|
||||
|
@ -65,30 +67,44 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
lastAppVersion != currentAppVersion
|
||||
)
|
||||
|
||||
guard shouldUploadTokens else { return Promise.value(()) }
|
||||
guard shouldUploadTokens else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
SyncPushTokensJob.registerForPushNotifications(
|
||||
pushToken: pushToken,
|
||||
voipToken: voipToken,
|
||||
isForcedUpdate: shouldUploadTokens,
|
||||
success: { seal.fulfill(()) },
|
||||
failure: seal.reject
|
||||
)
|
||||
|
||||
return promise
|
||||
.done(on: queue) { _ in
|
||||
Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))")
|
||||
return Future<Void, Error> { resolver in
|
||||
SyncPushTokensJob.registerForPushNotifications(
|
||||
pushToken: pushToken,
|
||||
voipToken: voipToken,
|
||||
isForcedUpdate: shouldUploadTokens,
|
||||
// TODO: Remove the 'Swift.'
|
||||
success: { resolver(Swift.Result.success(())) },
|
||||
// TODO: Remove the 'Swift.'
|
||||
failure: { resolver(Swift.Result.failure($0)) }
|
||||
)
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished:
|
||||
Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))")
|
||||
|
||||
Storage.shared.write { db in
|
||||
db[.lastRecordedPushToken] = pushToken
|
||||
db[.lastRecordedVoipToken] = voipToken
|
||||
Storage.shared.write { db in
|
||||
db[.lastRecordedPushToken] = pushToken
|
||||
db[.lastRecordedVoipToken] = voipToken
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.ensure(on: queue) { success(job, false) } // We want to complete this job regardless of success or failure
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete(
|
||||
// We want to complete this job regardless of success or failure
|
||||
receiveCompletion: { _ in success(job, false) },
|
||||
receiveValue: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
public static func run(uploadOnlyIfStale: Bool) {
|
||||
|
@ -134,19 +150,26 @@ extension SyncPushTokensJob {
|
|||
remainingRetries: Int = 3
|
||||
) {
|
||||
let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs]
|
||||
let pushTokenAsData = Data(hex: pushToken)
|
||||
let promise: Promise<Void> = (isUsingFullAPNs ?
|
||||
PushNotificationAPI.register(
|
||||
with: pushTokenAsData,
|
||||
publicKey: getUserHexEncodedPublicKey(),
|
||||
isForcedUpdate: isForcedUpdate
|
||||
) :
|
||||
PushNotificationAPI.unregister(pushTokenAsData)
|
||||
)
|
||||
|
||||
promise
|
||||
.done { success() }
|
||||
.catch { error in
|
||||
Just(Data(hex: pushToken))
|
||||
.setFailureType(to: Error.self)
|
||||
.flatMap { pushTokenAsData -> AnyPublisher<Bool, Error> in
|
||||
guard isUsingFullAPNs else {
|
||||
return PushNotificationAPI.unregister(pushTokenAsData)
|
||||
.map { _ in true }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return PushNotificationAPI
|
||||
.register(
|
||||
with: pushTokenAsData,
|
||||
publicKey: getUserHexEncodedPublicKey(),
|
||||
isForcedUpdate: isForcedUpdate
|
||||
)
|
||||
.map { _ in true }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.catch { error -> AnyPublisher<Bool, Error> in
|
||||
guard remainingRetries == 0 else {
|
||||
SyncPushTokensJob.registerForPushNotifications(
|
||||
pushToken: pushToken,
|
||||
|
@ -156,11 +179,28 @@ extension SyncPushTokensJob {
|
|||
failure: failure,
|
||||
remainingRetries: (remainingRetries - 1)
|
||||
)
|
||||
return
|
||||
|
||||
return Just(false)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
failure(error)
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
// TODO: Test these are called correctly
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error): failure(error)
|
||||
}
|
||||
},
|
||||
receiveValue: { didComplete in
|
||||
guard didComplete else { return }
|
||||
|
||||
success()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import PromiseKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class UserNotificationConfig {
|
||||
|
||||
|
@ -18,39 +19,47 @@ class UserNotificationConfig {
|
|||
}
|
||||
|
||||
class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory {
|
||||
return UNNotificationCategory(identifier: category.identifier,
|
||||
actions: notificationActions(for: category),
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
return UNNotificationCategory(
|
||||
identifier: category.identifier,
|
||||
actions: notificationActions(for: category),
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
}
|
||||
|
||||
class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction {
|
||||
switch action {
|
||||
case .markAsRead:
|
||||
return UNNotificationAction(identifier: action.identifier,
|
||||
title: MessageStrings.markAsReadNotificationAction,
|
||||
options: [])
|
||||
case .reply:
|
||||
return UNTextInputNotificationAction(identifier: action.identifier,
|
||||
title: MessageStrings.replyNotificationAction,
|
||||
options: [],
|
||||
textInputButtonTitle: MessageStrings.sendButton,
|
||||
textInputPlaceholder: "")
|
||||
case .showThread:
|
||||
return UNNotificationAction(identifier: action.identifier,
|
||||
title: CallStrings.showThreadButtonTitle,
|
||||
options: [.foreground])
|
||||
case .markAsRead:
|
||||
return UNNotificationAction(
|
||||
identifier: action.identifier,
|
||||
title: MessageStrings.markAsReadNotificationAction,
|
||||
options: []
|
||||
)
|
||||
|
||||
case .reply:
|
||||
return UNTextInputNotificationAction(
|
||||
identifier: action.identifier,
|
||||
title: MessageStrings.replyNotificationAction,
|
||||
options: [],
|
||||
textInputButtonTitle: MessageStrings.sendButton,
|
||||
textInputPlaceholder: ""
|
||||
)
|
||||
|
||||
case .showThread:
|
||||
return UNNotificationAction(
|
||||
identifier: action.identifier,
|
||||
title: CallStrings.showThreadButtonTitle,
|
||||
options: [.foreground]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class func action(identifier: String) -> AppNotificationAction? {
|
||||
return AppNotificationAction.allCases.first { notificationAction($0).identifier == identifier }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
private let notificationCenter: UNUserNotificationCenter
|
||||
private var notifications: [String: UNNotificationRequest] = [:]
|
||||
|
||||
|
@ -64,24 +73,23 @@ class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelega
|
|||
}
|
||||
|
||||
extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
||||
func registerNotificationSettings() -> Future<Void, Never> {
|
||||
return Future { [weak self] resolver in
|
||||
self?.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in
|
||||
self?.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories)
|
||||
|
||||
func registerNotificationSettings() -> Promise<Void> {
|
||||
return Promise { resolver in
|
||||
notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in
|
||||
self.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories)
|
||||
|
||||
if granted {
|
||||
|
||||
} else if error != nil {
|
||||
Logger.error("failed with error: \(error!)")
|
||||
} else {
|
||||
if granted {}
|
||||
else if let error: Error = error {
|
||||
Logger.error("failed with error: \(error)")
|
||||
}
|
||||
else {
|
||||
Logger.error("failed without error.")
|
||||
}
|
||||
|
||||
// Note that the promise is fulfilled regardless of if notification permssions were
|
||||
// granted. This promise only indicates that the user has responded, so we can
|
||||
// proceed with requesting push tokens and complete registration.
|
||||
resolver.fulfill(())
|
||||
resolver(Result.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -240,18 +248,22 @@ public class UserNotificationActionHandler: NSObject {
|
|||
@objc
|
||||
func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
firstly {
|
||||
try handleNotificationResponse(response)
|
||||
}.done {
|
||||
completionHandler()
|
||||
}.catch { error in
|
||||
completionHandler()
|
||||
owsFailDebug("error: \(error)")
|
||||
Logger.error("error: \(error)")
|
||||
}.retainUntilComplete()
|
||||
handleNotificationResponse(response)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
completionHandler()
|
||||
owsFailDebug("error: \(error)")
|
||||
Logger.error("error: \(error)")
|
||||
}
|
||||
},
|
||||
receiveValue: { _ in completionHandler() }
|
||||
)
|
||||
}
|
||||
|
||||
func handleNotificationResponse( _ response: UNNotificationResponse) throws -> Promise<Void> {
|
||||
func handleNotificationResponse( _ response: UNNotificationResponse) -> AnyPublisher<Void, Error> {
|
||||
AssertIsOnMainThread()
|
||||
assert(AppReadiness.isAppReady())
|
||||
|
||||
|
@ -260,12 +272,16 @@ public class UserNotificationActionHandler: NSObject {
|
|||
switch response.actionIdentifier {
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
Logger.debug("default action")
|
||||
return try actionHandler.showThread(userInfo: userInfo)
|
||||
return actionHandler.showThread(userInfo: userInfo)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
case UNNotificationDismissActionIdentifier:
|
||||
// TODO - mark as read?
|
||||
Logger.debug("dismissed notification")
|
||||
return Promise.value(())
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
default:
|
||||
// proceed
|
||||
|
@ -273,22 +289,26 @@ public class UserNotificationActionHandler: NSObject {
|
|||
}
|
||||
|
||||
guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else {
|
||||
throw NotificationError.failDebug("unable to find action for actionIdentifier: \(response.actionIdentifier)")
|
||||
return Fail(error: NotificationError.failDebug("unable to find action for actionIdentifier: \(response.actionIdentifier)"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .markAsRead:
|
||||
return try actionHandler.markAsRead(userInfo: userInfo)
|
||||
return actionHandler.markAsRead(userInfo: userInfo)
|
||||
|
||||
case .reply:
|
||||
guard let textInputResponse = response as? UNTextInputNotificationResponse else {
|
||||
throw NotificationError.failDebug("response had unexpected type: \(response)")
|
||||
return Fail(error: NotificationError.failDebug("response had unexpected type: \(response)"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
|
||||
return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
|
||||
|
||||
case .showThread:
|
||||
return try actionHandler.showThread(userInfo: userInfo)
|
||||
return actionHandler.showThread(userInfo: userInfo)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
|
|||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
.writePublisher { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: roomToken,
|
||||
|
@ -177,31 +177,39 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
|
|||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
let title = "COMMUNITY_ERROR_GENERIC".localized()
|
||||
let message = error.localizedDescription
|
||||
self?.isJoining = false
|
||||
self?.showError(title: title, message: message)
|
||||
|
||||
case .finished:
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.sinkUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
if shouldOpenCommunity {
|
||||
SessionApp.presentConversation(
|
||||
for: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
threadVariant: .openGroup,
|
||||
isMessageRequest: false,
|
||||
action: .compose,
|
||||
focusInteractionId: nil,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
if shouldOpenCommunity {
|
||||
SessionApp.presentConversation(
|
||||
for: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
threadVariant: .openGroup,
|
||||
isMessageRequest: false,
|
||||
action: .compose,
|
||||
focusInteractionId: nil,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
let title = "COMMUNITY_ERROR_GENERIC".localized()
|
||||
let message = error.localizedDescription
|
||||
self?.isJoining = false
|
||||
self?.showError(title: title, message: message)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import PromiseKit
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import NVActivityIndicatorView
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
|
@ -140,12 +143,10 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
|
|||
widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true
|
||||
|
||||
OpenGroupManager.getDefaultRoomsIfNeeded()
|
||||
.done { [weak self] rooms in
|
||||
self?.rooms = rooms
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
self?.update()
|
||||
}
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in self?.update() },
|
||||
receiveValue: { [weak self] rooms in self?.rooms = rooms }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
@ -315,24 +316,55 @@ extension OpenGroupSuggestionGrid {
|
|||
return
|
||||
}
|
||||
|
||||
let promise = Storage.shared.read { db in
|
||||
OpenGroupManager.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
||||
}
|
||||
imageView.image = nil // TODO: Test this
|
||||
|
||||
if let imageData: Data = promise.value {
|
||||
imageView.image = UIImage(data: imageData)
|
||||
imageView.isHidden = (imageView.image == nil)
|
||||
}
|
||||
else {
|
||||
imageView.isHidden = true
|
||||
|
||||
_ = promise.done { [weak self] imageData in
|
||||
DispatchQueue.main.async {
|
||||
Publishers
|
||||
.MergeMany(
|
||||
Storage.shared
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupManager
|
||||
.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
||||
}
|
||||
.map { ($0, true) }
|
||||
.eraseToAnyPublisher(),
|
||||
// If we have already received the room image then the above will emit first and
|
||||
// we can ignore this 'Just' call which is used to hide the image while loading
|
||||
Just((Data(), false))
|
||||
.setFailureType(to: Error.self)
|
||||
// .delay(for: .milliseconds(10), scheduler: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
)
|
||||
.receiveOnMain(immediately: true)
|
||||
.sinkUntilComplete(
|
||||
receiveValue: { [weak self] imageData, hasData in
|
||||
// TODO: Test this behaviour
|
||||
guard hasData else {
|
||||
self?.imageView.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
self?.imageView.image = UIImage(data: imageData)
|
||||
self?.imageView.isHidden = (self?.imageView.image == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// OpenGroupManager.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
||||
// .values
|
||||
//
|
||||
// if let imageData: Data = promise.value {
|
||||
// imageView.image = UIImage(data: imageData)
|
||||
// imageView.isHidden = (imageView.image == nil)
|
||||
// }
|
||||
// else {
|
||||
// imageView.isHidden = true
|
||||
//
|
||||
// _ = promise.done { [weak self] imageData in
|
||||
// DispatchQueue.main.async {
|
||||
// self?.imageView.image = UIImage(data: imageData)
|
||||
// self?.imageView.isHidden = (self?.imageView.image == nil)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -141,8 +141,88 @@ public class BlockedContactsViewModel {
|
|||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateContactData(_ updatedData: [SectionModel]) {
|
||||
self.contactData = updatedData
|
||||
private func unblockTapped() {
|
||||
guard !selectedContactIdsSubject.value.isEmpty else { return }
|
||||
|
||||
let contactIds: Set<String> = selectedContactIdsSubject.value
|
||||
let contactNames: [String] = contactIds
|
||||
.compactMap { contactId in
|
||||
guard
|
||||
let section: BlockedContactsViewModel.SectionModel = self.tableData
|
||||
.first(where: { section in section.model == .contacts }),
|
||||
let info: SessionCell.Info<Profile> = section.elements
|
||||
.first(where: { info in info.id.id == contactId })
|
||||
else { return contactId }
|
||||
|
||||
return info.title?.text
|
||||
}
|
||||
let confirmationTitle: String = {
|
||||
guard contactNames.count > 1 else {
|
||||
// Show a single users name
|
||||
return String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(),
|
||||
(
|
||||
contactNames.first ??
|
||||
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized()
|
||||
)
|
||||
)
|
||||
}
|
||||
guard contactNames.count > 3 else {
|
||||
// Show up to three users names
|
||||
let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1)))
|
||||
let lastName: String = contactNames[contactNames.count - 1]
|
||||
|
||||
return [
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
|
||||
initialNames.joined(separator: ", ")
|
||||
),
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(),
|
||||
lastName
|
||||
)
|
||||
]
|
||||
.reversed(if: CurrentAppContext().isRTL)
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
// If we have exactly 4 users, show the first two names followed by 'and X others', for
|
||||
// more than 4 users, show the first 3 names followed by 'and X others'
|
||||
let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3)
|
||||
let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow))
|
||||
|
||||
return [
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
|
||||
initialNames.joined(separator: ", ")
|
||||
),
|
||||
String(
|
||||
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(),
|
||||
(contactNames.count - numNamesToShow)
|
||||
)
|
||||
]
|
||||
.reversed(if: CurrentAppContext().isRTL)
|
||||
.joined(separator: " ")
|
||||
}()
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: confirmationTitle,
|
||||
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
) { _ in
|
||||
// Unblock the contacts
|
||||
Storage.shared.write { db in
|
||||
_ = try Contact
|
||||
.filter(ids: contactIds)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
self.transitionToScreen(confirmationModal, transitionType: .present)
|
||||
}
|
||||
|
||||
// MARK: - DataModel
|
||||
|
|
|
@ -150,62 +150,73 @@ final class NukeDataModal: Modal {
|
|||
private func clearDeviceOnly() {
|
||||
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) }
|
||||
.ensure(on: DispatchQueue.main) {
|
||||
self?.deleteAllLocalData()
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.writePublisherFlatMap { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
self?.deleteAllLocalData()
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearEntireAccount(presentedViewController: UIViewController) {
|
||||
ModalActivityIndicatorViewController
|
||||
.present(fromViewController: presentedViewController, canCancel: false) { [weak self] _ in
|
||||
SnodeAPI.clearAllData()
|
||||
.done(on: DispatchQueue.main) { confirmations in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let potentiallyMaliciousSnodes = confirmations.compactMap { $0.value == false ? $0.key : nil }
|
||||
|
||||
if potentiallyMaliciousSnodes.isEmpty {
|
||||
self?.deleteAllLocalData()
|
||||
}
|
||||
else {
|
||||
let message: String
|
||||
if potentiallyMaliciousSnodes.count == 1 {
|
||||
message = String(format: "dialog_clear_all_data_deletion_failed_1".localized(), potentiallyMaliciousSnodes[0])
|
||||
SnodeAPI.deleteAllMessages()
|
||||
.subscribe(on: DispatchQueue.global(qos: .default))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
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(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
},
|
||||
receiveValue: { confirmations in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let potentiallyMaliciousSnodes = confirmations
|
||||
.compactMap { ($0.value == false ? $0.key : nil) }
|
||||
|
||||
if potentiallyMaliciousSnodes.isEmpty {
|
||||
self?.deleteAllLocalData()
|
||||
}
|
||||
else {
|
||||
message = String(format: "dialog_clear_all_data_deletion_failed_2".localized(), String(potentiallyMaliciousSnodes.count), potentiallyMaliciousSnodes.joined(separator: ", "))
|
||||
}
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
let message: String
|
||||
if potentiallyMaliciousSnodes.count == 1 {
|
||||
message = String(format: "dialog_clear_all_data_deletion_failed_1".localized(), potentiallyMaliciousSnodes[0])
|
||||
}
|
||||
else {
|
||||
message = String(format: "dialog_clear_all_data_deletion_failed_2".localized(), String(potentiallyMaliciousSnodes.count), potentiallyMaliciousSnodes.joined(separator: ", "))
|
||||
}
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -216,7 +227,7 @@ final class NukeDataModal: Modal {
|
|||
|
||||
if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken {
|
||||
let data: Data = Data(hex: deviceToken)
|
||||
PushNotificationAPI.unregister(data).retainUntilComplete()
|
||||
PushNotificationAPI.unregister(data).sinkUntilComplete()
|
||||
}
|
||||
|
||||
// Clear the app badge and notifications
|
||||
|
|
|
@ -421,6 +421,47 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
}
|
||||
}
|
||||
|
||||
private func removeProfileImage() {
|
||||
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self] modalActivityIndicator in
|
||||
ProfileManager.updateLocal(
|
||||
queue: DispatchQueue.global(qos: .default),
|
||||
profileName: (self?.oldDisplayName ?? ""),
|
||||
image: nil,
|
||||
imageFilePath: nil,
|
||||
success: { db, updatedProfile in
|
||||
UserDefaults.standard[.lastProfilePictureUpdate] = Date()
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
|
||||
// Wait for the database transaction to complete before updating the UI
|
||||
db.afterNextTransaction { _ in
|
||||
DispatchQueue.main.async {
|
||||
modalActivityIndicator.dismiss(completion: {})
|
||||
}
|
||||
}
|
||||
},
|
||||
failure: { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Unable to remove avatar image",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
self.transitionToScreen(viewController, transitionType: .present)
|
||||
}
|
||||
|
||||
fileprivate func updateProfile(
|
||||
name: String,
|
||||
profilePicture: UIImage?,
|
||||
|
@ -448,8 +489,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
|||
UserDefaults.standard[.lastProfilePictureUpdate] = Date()
|
||||
}
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
|
||||
// Wait for the database transaction to complete before updating the UI
|
||||
db.afterNextTransaction { _ in
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
@ -1,68 +1,92 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionSnodeKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public final class BackgroundPoller {
|
||||
private static var promises: [Promise<Void>] = []
|
||||
private static var publishers: [AnyPublisher<Void, Error>] = []
|
||||
public static var isValid: Bool = false
|
||||
|
||||
public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
promises = []
|
||||
.appending(pollForMessages())
|
||||
.appending(contentsOf: pollForClosedGroupMessages())
|
||||
.appending(
|
||||
contentsOf: Storage.shared
|
||||
.read { db in
|
||||
// The default room promise creates an OpenGroup with an empty `roomToken` value,
|
||||
// we don't want to start a poller for this as the user hasn't actually joined a room
|
||||
try OpenGroup
|
||||
.select(.server)
|
||||
.filter(OpenGroup.Columns.roomToken != "")
|
||||
.filter(OpenGroup.Columns.isActive)
|
||||
.distinct()
|
||||
.asRequest(of: String.self)
|
||||
.fetchSet(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.map { server in
|
||||
let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server)
|
||||
poller.stop()
|
||||
|
||||
return poller.poll(
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollerValid: { BackgroundPoller.isValid },
|
||||
isPostCapabilitiesRetry: false
|
||||
)
|
||||
}
|
||||
// TODO: Test this works
|
||||
Publishers
|
||||
.MergeMany(
|
||||
[pollForMessages()]
|
||||
.appending(contentsOf: pollForClosedGroupMessages())
|
||||
.appending(
|
||||
contentsOf: Storage.shared
|
||||
.read { db in
|
||||
// The default room promise creates an OpenGroup with an empty
|
||||
// `roomToken` value, we don't want to start a poller for this
|
||||
// as the user hasn't actually joined a room
|
||||
try OpenGroup
|
||||
.select(.server)
|
||||
.filter(OpenGroup.Columns.roomToken != "")
|
||||
.filter(OpenGroup.Columns.isActive)
|
||||
.distinct()
|
||||
.asRequest(of: String.self)
|
||||
.fetchSet(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.map { server -> AnyPublisher<Void, Error> in
|
||||
let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server)
|
||||
poller.stop()
|
||||
|
||||
return poller.poll(
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollerValid: { BackgroundPoller.isValid },
|
||||
isPostCapabilitiesRetry: false
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.subscribe(on: DispatchQueue.main)
|
||||
.receiveOnMain(immediately: true)
|
||||
.collect()
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
// If we have already invalidated the timer then do nothing (we essentially timed out)
|
||||
guard BackgroundPoller.isValid else { return }
|
||||
|
||||
switch result {
|
||||
case .finished: completionHandler(.newData)
|
||||
case .failure(let error):
|
||||
SNLog("Background poll failed due to error: \(error)")
|
||||
completionHandler(.failed)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when(resolved: promises)
|
||||
.done { _ in
|
||||
// If we have already invalidated the timer then do nothing (we essentially timed out)
|
||||
guard BackgroundPoller.isValid else { return }
|
||||
|
||||
completionHandler(.newData)
|
||||
}
|
||||
.catch { error in
|
||||
// If we have already invalidated the timer then do nothing (we essentially timed out)
|
||||
guard BackgroundPoller.isValid else { return }
|
||||
|
||||
SNLog("Background poll failed due to error: \(error)")
|
||||
completionHandler(.failed)
|
||||
}
|
||||
}
|
||||
|
||||
private static func pollForMessages() -> Promise<Void> {
|
||||
private static func pollForMessages() -> AnyPublisher<Void, Error> {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
return getMessages(for: userPublicKey)
|
||||
|
||||
return SnodeAPI.getSwarm(for: userPublicKey)
|
||||
.subscribe(on: DispatchQueue.main)
|
||||
.receiveOnMain(immediately: true)
|
||||
.flatMap { swarm -> AnyPublisher<Void, Error> in
|
||||
guard let snode = swarm.randomElement() else {
|
||||
return Fail(error: SnodeAPIError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return CurrentUserPoller.poll(
|
||||
namespaces: CurrentUserPoller.namespaces,
|
||||
from: snode,
|
||||
for: userPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private static func pollForClosedGroupMessages() -> [Promise<Void>] {
|
||||
private static func pollForClosedGroupMessages() -> [AnyPublisher<Void, Error>] {
|
||||
// Fetch all closed groups (excluding any don't contain the current user as a
|
||||
// GroupMemeber as the user is no longer a member of those)
|
||||
return Storage.shared
|
||||
|
@ -78,90 +102,13 @@ public final class BackgroundPoller {
|
|||
}
|
||||
.defaulting(to: [])
|
||||
.map { groupPublicKey in
|
||||
ClosedGroupPoller.poll(
|
||||
groupPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
maxRetryCount: 0,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func getMessages(for publicKey: String) -> Promise<Void> {
|
||||
return SnodeAPI.getSwarm(for: publicKey)
|
||||
.then(on: DispatchQueue.main) { swarm -> Promise<Void> in
|
||||
guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic }
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: publicKey)
|
||||
.then(on: DispatchQueue.main) { messages, lastHash -> Promise<Void> in
|
||||
guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) }
|
||||
|
||||
var jobsToRun: [Job] = []
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
Storage.shared.write { db in
|
||||
messages
|
||||
.compactMap { message -> ProcessedMessage? in
|
||||
do {
|
||||
return try Message.processRawReceivedMessage(db, rawMessage: message)
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate & selfSend message errors (and don't bother
|
||||
// logging them as there will be a lot since we each service node
|
||||
// duplicates messages)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||
MessageReceiverError.duplicateMessage,
|
||||
MessageReceiverError.duplicateControlMessage,
|
||||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
case MessageReceiverError.duplicateMessageNewSnode:
|
||||
hadValidHashUpdate = true
|
||||
break
|
||||
|
||||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
case DatabaseError.SQLITE_ABORT: break
|
||||
|
||||
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.grouped { threadId, _, _ in (threadId ?? Message.nonThreadMessageId) }
|
||||
.forEach { threadId, threadMessages in
|
||||
messageCount += threadMessages.count
|
||||
|
||||
let maybeJob: Job? = Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: threadMessages.map { $0.messageInfo },
|
||||
calledFromBackgroundPoller: true
|
||||
)
|
||||
)
|
||||
|
||||
guard let job: Job = maybeJob else { return }
|
||||
|
||||
// Add to the JobRunner so they are persistent and will retry on
|
||||
// the next app run if they fail
|
||||
JobRunner.add(db, job: job, canStartJob: false)
|
||||
jobsToRun.append(job)
|
||||
}
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash {
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: [lastHash],
|
||||
otherKnownValidHashes: messages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
SnodeAPI.getSwarm(for: groupPublicKey)
|
||||
.subscribe(on: DispatchQueue.main)
|
||||
.receiveOnMain(immediately: true)
|
||||
.flatMap { swarm -> AnyPublisher<Void, Error> in
|
||||
guard let snode: Snode = swarm.randomElement() else {
|
||||
return Fail(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let promises: [Promise<Void>] = jobsToRun.map { job -> Promise<Void> in
|
||||
|
@ -181,6 +128,7 @@ public final class BackgroundPoller {
|
|||
|
||||
return when(fulfilled: promises)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CGRect {
|
||||
|
||||
init(center: CGPoint, size: CGSize) {
|
||||
let originX = center.x - size.width / 2
|
||||
let originY = center.y - size.height / 2
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
import AVFAudio
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "attachment" }
|
||||
|
@ -975,7 +975,7 @@ extension Attachment {
|
|||
internal func upload(
|
||||
_ db: Database? = nil,
|
||||
queue: DispatchQueue,
|
||||
using upload: (Database, Data) -> Promise<String>,
|
||||
using upload: @escaping (Database, Data) -> AnyPublisher<String, Error>,
|
||||
encrypt: Bool,
|
||||
success: ((String?) -> Void)?,
|
||||
failure: ((Error) -> Void)?
|
||||
|
@ -1093,50 +1093,56 @@ extension Attachment {
|
|||
}
|
||||
|
||||
// Perform the upload
|
||||
let uploadPromise: Promise<String> = {
|
||||
let uploadPublisher: AnyPublisher<String, Error> = {
|
||||
guard let db: Database = db else {
|
||||
return Storage.shared.read { db in upload(db, data) }
|
||||
return Storage.shared.readPublisherFlatMap { db in upload(db, data) }
|
||||
}
|
||||
|
||||
return upload(db, data)
|
||||
}()
|
||||
|
||||
uploadPromise
|
||||
.done(on: queue) { fileId in
|
||||
/// Save the final upload info
|
||||
///
|
||||
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
|
||||
/// updated correctly
|
||||
let uploadedAttachment: Attachment? = Storage.shared.write { db in
|
||||
try updatedAttachment?
|
||||
.with(
|
||||
serverId: "\(fileId)",
|
||||
state: .uploaded,
|
||||
creationTimestamp: (
|
||||
updatedAttachment?.creationTimestamp ??
|
||||
Date().timeIntervalSince1970
|
||||
),
|
||||
downloadUrl: "\(FileServerAPI.server)/files/\(fileId)"
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
guard uploadedAttachment != nil else {
|
||||
SNLog("Couldn't update attachmentUpload job.")
|
||||
failure?(StorageError.failedToSave)
|
||||
return
|
||||
}
|
||||
uploadPublisher
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
Storage.shared.write { db in
|
||||
try Attachment
|
||||
.filter(id: attachmentId)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
|
||||
}
|
||||
|
||||
failure?(error)
|
||||
}
|
||||
},
|
||||
receiveValue: { fileId in
|
||||
/// Save the final upload info
|
||||
///
|
||||
/// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is
|
||||
/// updated correctly
|
||||
let uploadedAttachment: Attachment? = Storage.shared.write { db in
|
||||
try updatedAttachment?
|
||||
.with(
|
||||
serverId: "\(fileId)",
|
||||
state: .uploaded,
|
||||
creationTimestamp: (
|
||||
updatedAttachment?.creationTimestamp ??
|
||||
Date().timeIntervalSince1970
|
||||
),
|
||||
downloadUrl: "\(FileServerAPI.server)/files/\(fileId)"
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
success?(fileId)
|
||||
}
|
||||
.catch(on: queue) { error in
|
||||
Storage.shared.write { db in
|
||||
try Attachment
|
||||
.filter(id: attachmentId)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
|
||||
guard uploadedAttachment != nil else {
|
||||
SNLog("Couldn't update attachmentUpload job.")
|
||||
failure?(StorageError.failedToSave)
|
||||
return
|
||||
}
|
||||
|
||||
success?(fileId)
|
||||
}
|
||||
|
||||
failure?(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,3 +88,76 @@ public extension ClosedGroup {
|
|||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
public extension ClosedGroup {
|
||||
func asProfile() -> Profile {
|
||||
return Profile(
|
||||
id: threadId,
|
||||
name: name,
|
||||
profilePictureUrl: groupImageUrl,
|
||||
profilePictureFileName: groupImageFileName,
|
||||
profileEncryptionKey: groupImageEncryptionKey
|
||||
)
|
||||
}
|
||||
|
||||
static func removeKeysAndUnsubscribe(
|
||||
_ db: Database? = nil,
|
||||
threadId: String,
|
||||
removeGroupData: Bool = false
|
||||
) throws {
|
||||
try removeKeysAndUnsubscribe(db, threadIds: [threadId], removeGroupData: removeGroupData)
|
||||
}
|
||||
|
||||
static func removeKeysAndUnsubscribe(
|
||||
_ db: Database? = nil,
|
||||
threadIds: [String],
|
||||
removeGroupData: Bool = false
|
||||
) throws {
|
||||
guard let db: Database = db else {
|
||||
Storage.shared.write { db in
|
||||
try ClosedGroup.removeKeysAndUnsubscribe(
|
||||
db,
|
||||
threadIds: threadIds,
|
||||
removeGroupData: removeGroupData)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the group from the database and unsubscribe from PNs
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
threadIds.forEach { threadId in
|
||||
ClosedGroupPoller.shared.stopPolling(for: threadId)
|
||||
|
||||
PushNotificationAPI
|
||||
.performOperation(
|
||||
.unsubscribe,
|
||||
for: threadId,
|
||||
publicKey: userPublicKey
|
||||
)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
// Remove the keys for the group
|
||||
try ClosedGroupKeyPair
|
||||
.filter(threadIds.contains(ClosedGroupKeyPair.Columns.threadId))
|
||||
.deleteAll(db)
|
||||
|
||||
// Remove the remaining group data if desired
|
||||
if removeGroupData {
|
||||
try SessionThread
|
||||
.filter(ids: threadIds)
|
||||
.deleteAll(db)
|
||||
|
||||
try ClosedGroup
|
||||
.filter(ids: threadIds)
|
||||
.deleteAll(db)
|
||||
|
||||
try GroupMember
|
||||
.filter(threadIds.contains(GroupMember.Columns.groupId))
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +1,33 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc(SNFileServerAPI)
|
||||
public final class FileServerAPI: NSObject {
|
||||
public enum FileServerAPI {
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
@objc public static let oldServer = "http://88.99.175.227"
|
||||
public static let oldServer = "http://88.99.175.227"
|
||||
public static let oldServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69"
|
||||
@objc public static let server = "http://filev2.getsession.org"
|
||||
public static let server = "http://filev2.getsession.org"
|
||||
public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59"
|
||||
public static let maxFileSize = (10 * 1024 * 1024) // 10 MB
|
||||
/// The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes
|
||||
/// is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP
|
||||
/// request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also
|
||||
/// be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when
|
||||
/// uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only
|
||||
/// possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds.
|
||||
|
||||
/// The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit
|
||||
/// applied by the Service Nodes is on the **HTTP request** and not the actual file size. Because the file server expects the
|
||||
/// file data to be base 64 encoded, the size of the HTTP request for a given file will be at least `ceil(n / 3) * 4` bytes,
|
||||
/// where n is the file size in bytes. This is the minimum size because there might also be other parameters in the request. On
|
||||
/// average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when uploading
|
||||
/// a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request
|
||||
/// but that's only possible after proof of work has been calculated and the onion request encryption has happened, which takes
|
||||
/// several seconds.
|
||||
public static let fileSizeORMultiplier: Double = 2
|
||||
|
||||
// MARK: - File Storage
|
||||
|
||||
public static func upload(_ file: Data) -> Promise<FileUploadResponse> {
|
||||
public static func upload(_ file: Data) -> AnyPublisher<FileUploadResponse, Error> {
|
||||
let request = Request(
|
||||
method: .post,
|
||||
server: server,
|
||||
|
@ -38,10 +40,10 @@ public final class FileServerAPI: NSObject {
|
|||
)
|
||||
|
||||
return send(request, serverPublicKey: serverPublicKey)
|
||||
.decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated))
|
||||
.decoded(as: FileUploadResponse.self)
|
||||
}
|
||||
|
||||
public static func download(_ fileId: String, useOldServer: Bool) -> Promise<Data> {
|
||||
public static func download(_ fileId: String, useOldServer: Bool) -> AnyPublisher<Data, Error> {
|
||||
let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey)
|
||||
let request = Request<NoBody, Endpoint>(
|
||||
server: (useOldServer ? oldServer : server),
|
||||
|
@ -51,7 +53,7 @@ public final class FileServerAPI: NSObject {
|
|||
return send(request, serverPublicKey: serverPublicKey)
|
||||
}
|
||||
|
||||
public static func getVersion(_ platform: String) -> Promise<String> {
|
||||
public static func getVersion(_ platform: String) -> AnyPublisher<String, Error> {
|
||||
let request = Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .sessionVersion,
|
||||
|
@ -61,27 +63,39 @@ public final class FileServerAPI: NSObject {
|
|||
)
|
||||
|
||||
return send(request, serverPublicKey: serverPublicKey)
|
||||
.decoded(as: VersionResponse.self, on: .global(qos: .userInitiated))
|
||||
.decoded(as: VersionResponse.self)
|
||||
.map { response in response.version }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
private static func send<T: Encodable>(_ request: Request<T, Endpoint>, serverPublicKey: String) -> Promise<Data> {
|
||||
private static func send<T: Encodable>(
|
||||
_ request: Request<T, Endpoint>,
|
||||
serverPublicKey: String
|
||||
) -> AnyPublisher<Data, Error> {
|
||||
let urlRequest: URLRequest
|
||||
|
||||
do {
|
||||
urlRequest = try request.generateUrlRequest()
|
||||
}
|
||||
catch {
|
||||
return Promise(error: error)
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, with: serverPublicKey)
|
||||
.map2 { _, response in
|
||||
guard let response: Data = response else { throw HTTP.Error.parsingFailed }
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { _, response -> AnyPublisher<Data, Error> in
|
||||
guard let response: Data = response else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return response
|
||||
return Just(response)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
import SignalCoreKit
|
||||
|
@ -84,119 +84,150 @@ public enum AttachmentDownloadJob: JobExecutor {
|
|||
let temporaryFileUrl: URL = URL(
|
||||
fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString
|
||||
)
|
||||
let downloadPromise: Promise<Data> = {
|
||||
guard
|
||||
let downloadUrl: String = attachment.downloadUrl,
|
||||
let fileId: String = Attachment.fileId(for: downloadUrl)
|
||||
else {
|
||||
return Promise(error: AttachmentDownloadError.invalidUrl)
|
||||
}
|
||||
|
||||
let maybeOpenGroupDownloadPromise: Promise<Data>? = Storage.shared.read({ db in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
|
||||
return nil // Not an open group so just use standard FileServer upload
|
||||
}
|
||||
|
||||
return OpenGroupAPI.downloadFile(db, fileId: fileId, from: openGroup.roomToken, on: openGroup.server)
|
||||
.map { _, data in data }
|
||||
})
|
||||
|
||||
return (
|
||||
maybeOpenGroupDownloadPromise ??
|
||||
FileServerAPI.download(fileId, useOldServer: downloadUrl.contains(FileServerAPI.oldServer))
|
||||
)
|
||||
}()
|
||||
|
||||
downloadPromise
|
||||
.then(on: queue) { data -> Promise<Void> in
|
||||
try data.write(to: temporaryFileUrl, options: .atomic)
|
||||
|
||||
let plaintext: Data = try {
|
||||
guard
|
||||
let key: Data = attachment.encryptionKey,
|
||||
let digest: Data = attachment.digest,
|
||||
key.count > 0,
|
||||
digest.count > 0
|
||||
else { return data } // Open group attachments are unencrypted
|
||||
|
||||
return try Cryptography.decryptAttachment(
|
||||
data,
|
||||
withKey: key,
|
||||
digest: digest,
|
||||
unpaddedSize: UInt32(attachment.byteCount)
|
||||
)
|
||||
}()
|
||||
|
||||
guard try attachment.write(data: plaintext) else {
|
||||
throw AttachmentDownloadError.failedToSaveFile
|
||||
Just(attachment.downloadUrl)
|
||||
.setFailureType(to: Error.self)
|
||||
.flatMap { maybeDownloadUrl -> AnyPublisher<Data, Error> in
|
||||
guard
|
||||
let downloadUrl: String = maybeDownloadUrl,
|
||||
let fileId: String = Attachment.fileId(for: downloadUrl)
|
||||
else {
|
||||
return Fail(error: AttachmentDownloadError.invalidUrl)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Promise.value(())
|
||||
}
|
||||
.done(on: queue) {
|
||||
// Remove the temporary file
|
||||
OWSFileSystem.deleteFile(temporaryFileUrl.path)
|
||||
|
||||
/// Update the attachment state
|
||||
///
|
||||
/// **Note:** We **MUST** use the `'with()` function here as it will update the
|
||||
/// `isValid` and `duration` values based on the downloaded data and the state
|
||||
Storage.shared.write { db in
|
||||
_ = try attachment
|
||||
.with(
|
||||
state: .downloaded,
|
||||
creationTimestamp: Date().timeIntervalSince1970,
|
||||
localRelativeFilePath: (
|
||||
attachment.localRelativeFilePath ??
|
||||
Attachment.localRelativeFilePath(from: attachment.originalFilePath)
|
||||
)
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
success(job, false)
|
||||
}
|
||||
.catch(on: queue) { error in
|
||||
OWSFileSystem.deleteFile(temporaryFileUrl.path)
|
||||
|
||||
let targetState: Attachment.State
|
||||
let permanentFailure: Bool
|
||||
|
||||
switch error {
|
||||
/// If we get a 404 then we got a successful response from the server but the attachment doesn't
|
||||
/// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in
|
||||
/// a retry download loop
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 404:
|
||||
targetState = .invalid
|
||||
permanentFailure = true
|
||||
return Storage.shared
|
||||
.readPublisher { db in try OpenGroup.fetchOne(db, id: threadId) }
|
||||
.flatMap { maybeOpenGroup -> AnyPublisher<Data, Error> in
|
||||
guard let openGroup: OpenGroup = maybeOpenGroup else {
|
||||
return FileServerAPI
|
||||
.download(
|
||||
fileId,
|
||||
useOldServer: downloadUrl.contains(FileServerAPI.oldServer)
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400 || statusCode == 401:
|
||||
/// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's
|
||||
/// likely something else is going on that caused the failure
|
||||
targetState = .failedDownload
|
||||
permanentFailure = true
|
||||
return Storage.shared
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI
|
||||
.downloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
}
|
||||
.map { _, data in data }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.flatMap { data -> AnyPublisher<Void, Error> in
|
||||
do {
|
||||
// Store the encrypted data temporarily
|
||||
try data.write(to: temporaryFileUrl, options: .atomic)
|
||||
|
||||
/// For any other error it's likely either the server is down or something weird just happened with the request
|
||||
/// so we want to automatically retry
|
||||
default:
|
||||
targetState = .failedDownload
|
||||
permanentFailure = false
|
||||
// Decrypt the data
|
||||
let plaintext: Data = try {
|
||||
guard
|
||||
let key: Data = attachment.encryptionKey,
|
||||
let digest: Data = attachment.digest,
|
||||
key.count > 0,
|
||||
digest.count > 0
|
||||
else { return data } // Open group attachments are unencrypted
|
||||
|
||||
return try Cryptography.decryptAttachment(
|
||||
data,
|
||||
withKey: key,
|
||||
digest: digest,
|
||||
unpaddedSize: UInt32(attachment.byteCount)
|
||||
)
|
||||
}()
|
||||
|
||||
// Write the data to disk
|
||||
guard try attachment.write(data: plaintext) else {
|
||||
throw AttachmentDownloadError.failedToSaveFile
|
||||
}
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// To prevent the attachment from showing a state of downloading forever, we need to update the attachment
|
||||
/// state here based on the type of error that occurred
|
||||
///
|
||||
/// **Note:** We **MUST** use the `'with()` function here as it will update the
|
||||
/// `isValid` and `duration` values based on the downloaded data and the state
|
||||
Storage.shared.write { db in
|
||||
_ = try Attachment
|
||||
.filter(id: attachment.id)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: targetState))
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Trigger the failure and provide the `permanentFailure` value defined above
|
||||
failure(job, error, permanentFailure)
|
||||
}
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
// Remove the temporary file
|
||||
OWSFileSystem.deleteFile(temporaryFileUrl.path)
|
||||
|
||||
/// Update the attachment state
|
||||
///
|
||||
/// **Note:** We **MUST** use the `'with()` function here as it will update the
|
||||
/// `isValid` and `duration` values based on the downloaded data and the state
|
||||
Storage.shared.write { db in
|
||||
_ = try attachment
|
||||
.with(
|
||||
state: .downloaded,
|
||||
creationTimestamp: Date().timeIntervalSince1970,
|
||||
localRelativeFilePath: (
|
||||
attachment.localRelativeFilePath ??
|
||||
Attachment.localRelativeFilePath(from: attachment.originalFilePath)
|
||||
)
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
success(job, false)
|
||||
|
||||
case .failure(let error):
|
||||
OWSFileSystem.deleteFile(temporaryFileUrl.path)
|
||||
|
||||
let targetState: Attachment.State
|
||||
let permanentFailure: Bool
|
||||
|
||||
switch error {
|
||||
/// If we get a 404 then we got a successful response from the server but the attachment doesn't
|
||||
/// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in
|
||||
/// a retry download loop
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 404:
|
||||
targetState = .invalid
|
||||
permanentFailure = true
|
||||
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400 || statusCode == 401:
|
||||
/// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's
|
||||
/// likely something else is going on that caused the failure
|
||||
targetState = .failedDownload
|
||||
permanentFailure = true
|
||||
|
||||
/// For any other error it's likely either the server is down or something weird just happened with the request
|
||||
/// so we want to automatically retry
|
||||
default:
|
||||
targetState = .failedDownload
|
||||
permanentFailure = false
|
||||
}
|
||||
|
||||
/// To prevent the attachment from showing a state of downloading forever, we need to update the attachment
|
||||
/// state here based on the type of error that occurred
|
||||
///
|
||||
/// **Note:** We **MUST** use the `'with()` function here as it will update the
|
||||
/// `isValid` and `duration` values based on the downloaded data and the state
|
||||
Storage.shared.write { db in
|
||||
_ = try Attachment
|
||||
.filter(id: attachment.id)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: targetState))
|
||||
}
|
||||
|
||||
/// Trigger the failure and provide the `permanentFailure` value defined above
|
||||
failure(job, error, permanentFailure)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -64,10 +64,12 @@ public enum AttachmentUploadJob: JobExecutor {
|
|||
on: openGroup.server
|
||||
)
|
||||
.map { _, response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return FileServerAPI.upload(data)
|
||||
.map { response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
},
|
||||
encrypt: (openGroup == nil),
|
||||
success: { _ in success(job, false) },
|
||||
|
|
|
@ -160,48 +160,58 @@ public enum MessageSendJob: JobExecutor {
|
|||
details.message.threadId = (details.message.threadId ?? job.threadId)
|
||||
|
||||
// Perform the actual message sending
|
||||
Storage.shared.writeAsync { db -> Promise<Void> in
|
||||
try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: details.message,
|
||||
to: details.destination
|
||||
.with(fileIds: messageFileIds),
|
||||
interactionId: job.interactionId
|
||||
)
|
||||
}
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in
|
||||
SNLog("Couldn't send message due to error: \(error).")
|
||||
|
||||
switch error {
|
||||
case let senderError as MessageSenderError where !senderError.isRetryable:
|
||||
failure(job, error, true)
|
||||
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited
|
||||
failure(job, error, true)
|
||||
|
||||
case SnodeAPIError.clockOutOfSync:
|
||||
SNLog("\(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.")
|
||||
failure(job, error, (originalSentTimestamp != nil))
|
||||
|
||||
default:
|
||||
SNLog("Failed to send \(type(of: details.message)).")
|
||||
|
||||
if details.message is VisibleMessage {
|
||||
guard
|
||||
let interactionId: Int64 = job.interactionId,
|
||||
Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true
|
||||
else {
|
||||
// The message has been deleted so permanently fail the job
|
||||
failure(job, error, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
failure(job, error, false)
|
||||
Storage.shared
|
||||
.writePublisher { db in
|
||||
// TODO: Will need to split the attachment upload from the message preparation logic
|
||||
try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: details.message,
|
||||
to: details.destination
|
||||
.with(fileIds: messageFileIds), // TODO: This???
|
||||
interactionId: job.interactionId
|
||||
)
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.subscribe(on: queue)
|
||||
// TODO: Is this needed? (should be caught before this??)
|
||||
// .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) }
|
||||
.flatMap { MessageSender.sendImmediate(data: $0) }
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: success(job, false)
|
||||
case .failure(let error):
|
||||
SNLog("Couldn't send message due to error: \(error).")
|
||||
|
||||
switch error {
|
||||
case let senderError as MessageSenderError where !senderError.isRetryable:
|
||||
failure(job, error, true)
|
||||
|
||||
case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited
|
||||
failure(job, error, true)
|
||||
|
||||
case SnodeAPIError.clockOutOfSync:
|
||||
SNLog("\(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.")
|
||||
failure(job, error, (originalSentTimestamp != nil))
|
||||
|
||||
default:
|
||||
SNLog("Failed to send \(type(of: details.message)).")
|
||||
|
||||
if details.message is VisibleMessage {
|
||||
guard
|
||||
let interactionId: Int64 = job.interactionId,
|
||||
Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true
|
||||
else {
|
||||
// The message has been deleted so permanently fail the job
|
||||
failure(job, error, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
failure(job, error, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -29,12 +29,18 @@ public enum NotifyPushServerJob: JobExecutor {
|
|||
.notify(
|
||||
recipient: details.message.recipient,
|
||||
with: details.message.data,
|
||||
maxRetryCount: 4,
|
||||
queue: queue
|
||||
maxRetryCount: 4
|
||||
)
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: success(job, false)
|
||||
case .failure(let error): failure(job, error, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -42,8 +42,15 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor {
|
|||
}
|
||||
|
||||
OpenGroupManager.getDefaultRoomsIfNeeded()
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: success(job, false)
|
||||
case .failure(let error): failure(job, error, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,8 +36,8 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
}
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
try MessageSender.sendImmediate(
|
||||
.writePublisher { db in
|
||||
try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: ReadReceipt(
|
||||
timestamps: details.timestampMsValues.map { UInt64($0) }
|
||||
|
@ -46,42 +46,49 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
interactionId: nil
|
||||
)
|
||||
}
|
||||
.done(on: queue) {
|
||||
// When we complete the 'SendReadReceiptsJob' we want to immediately schedule
|
||||
// another one for the same thread but with a 'nextRunTimestamp' set to the
|
||||
// 'minRunFrequency' value to throttle the read receipt requests
|
||||
var shouldFinishCurrentJob: Bool = false
|
||||
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency)
|
||||
|
||||
let updatedJob: Job? = Storage.shared.write { db in
|
||||
// If another 'sendReadReceipts' job was scheduled then update that one
|
||||
// to run at 'nextRunTimestamp' and make the current job stop
|
||||
if
|
||||
let existingJob: Job = try? Job
|
||||
.filter(Job.Columns.id != job.id)
|
||||
.filter(Job.Columns.variant == Job.Variant.sendReadReceipts)
|
||||
.filter(Job.Columns.threadId == threadId)
|
||||
.fetchOne(db),
|
||||
!JobRunner.isCurrentlyRunning(existingJob)
|
||||
{
|
||||
_ = try existingJob
|
||||
.with(nextRunTimestamp: nextRunTimestamp)
|
||||
.saved(db)
|
||||
shouldFinishCurrentJob = true
|
||||
return job
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.flatMap { MessageSender.sendImmediate(data: $0) }
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure(let error): failure(job, error, false)
|
||||
case .finished:
|
||||
// When we complete the 'SendReadReceiptsJob' we want to immediately schedule
|
||||
// another one for the same thread but with a 'nextRunTimestamp' set to the
|
||||
// 'minRunFrequency' value to throttle the read receipt requests
|
||||
var shouldFinishCurrentJob: Bool = false
|
||||
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency)
|
||||
|
||||
let updatedJob: Job? = Storage.shared.write { db in
|
||||
// If another 'sendReadReceipts' job was scheduled then update that one
|
||||
// to run at 'nextRunTimestamp' and make the current job stop
|
||||
if
|
||||
let existingJob: Job = try? Job
|
||||
.filter(Job.Columns.id != job.id)
|
||||
.filter(Job.Columns.variant == Job.Variant.sendReadReceipts)
|
||||
.filter(Job.Columns.threadId == threadId)
|
||||
.fetchOne(db),
|
||||
!JobRunner.isCurrentlyRunning(existingJob)
|
||||
{
|
||||
_ = try existingJob
|
||||
.with(nextRunTimestamp: nextRunTimestamp)
|
||||
.saved(db)
|
||||
shouldFinishCurrentJob = true
|
||||
return job
|
||||
}
|
||||
|
||||
return try job
|
||||
.with(details: Details(destination: details.destination, timestampMsValues: []))
|
||||
.defaulting(to: job)
|
||||
.with(nextRunTimestamp: nextRunTimestamp)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
success(updatedJob ?? job, shouldFinishCurrentJob)
|
||||
}
|
||||
|
||||
return try job
|
||||
.with(details: Details(destination: details.destination, timestampMsValues: []))
|
||||
.defaulting(to: job)
|
||||
.with(nextRunTimestamp: nextRunTimestamp)
|
||||
.saved(db)
|
||||
}
|
||||
|
||||
success(updatedJob ?? job, shouldFinishCurrentJob)
|
||||
}
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ public enum UpdateProfilePictureJob: JobExecutor {
|
|||
image: nil,
|
||||
imageFilePath: profileFilePath,
|
||||
success: { db, _ in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
|
||||
// Need to call the 'success' closure asynchronously on the queue to prevent a reentrancy
|
||||
// issue as it will write to the database and this closure is already called within
|
||||
|
|
|
@ -24,7 +24,7 @@ public extension Message {
|
|||
)
|
||||
case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String)
|
||||
|
||||
static func from(
|
||||
public static func from(
|
||||
_ db: Database,
|
||||
thread: SessionThread,
|
||||
fileIds: [String]? = nil
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import SessionUtilitiesKit
|
||||
|
||||
internal extension OpenGroupAPI {
|
||||
|
@ -104,18 +104,20 @@ internal extension OpenGroupAPI {
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
internal extension Promise where T == HTTP.BatchResponse {
|
||||
internal extension AnyPublisher where Output == HTTP.BatchResponse, Failure == Error {
|
||||
func map<E: EndpointType>(
|
||||
requests: [OpenGroupAPI.BatchRequest.Info],
|
||||
toHashMapFor endpointType: E.Type
|
||||
) -> Promise<[E: (ResponseInfoType, Codable?)]> {
|
||||
return self.map { result in
|
||||
result.enumerated()
|
||||
.reduce(into: [:]) { prev, next in
|
||||
guard let endpoint: E = requests[next.offset].endpoint as? E else { return }
|
||||
|
||||
prev[endpoint] = next.element
|
||||
}
|
||||
}
|
||||
) -> AnyPublisher<[E: (ResponseInfoType, Codable?)], Error> {
|
||||
return self
|
||||
.map { result in
|
||||
result.enumerated()
|
||||
.reduce(into: [:]) { prev, next in
|
||||
guard let endpoint: E = requests[next.offset].endpoint as? E else { return }
|
||||
|
||||
prev[endpoint] = next.element
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import Sodium
|
||||
import Curve25519Kit
|
||||
import SessionSnodeKit
|
||||
|
@ -33,7 +33,7 @@ public enum OpenGroupAPI {
|
|||
hasPerformedInitialPoll: Bool,
|
||||
timeSinceLastPoll: TimeInterval,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
|
||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||
let lastInboxMessageId: Int64 = (try? OpenGroup
|
||||
.select(.inboxLatestMessageId)
|
||||
.filter(OpenGroup.Columns.server == server)
|
||||
|
@ -153,8 +153,7 @@ public enum OpenGroupAPI {
|
|||
server: String,
|
||||
requests: [BatchRequestInfoType],
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
|
||||
let requestBody: BatchRequest = requests.map { $0.toSubRequest() }
|
||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||
let responseTypes = requests.map { $0.responseType }
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -168,13 +167,8 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.map { result in
|
||||
result.enumerated()
|
||||
.reduce(into: [:]) { prev, next in
|
||||
prev[requests[next.offset].endpoint] = next.element
|
||||
}
|
||||
}
|
||||
.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
|
||||
|
@ -191,8 +185,7 @@ public enum OpenGroupAPI {
|
|||
server: String,
|
||||
requests: [BatchRequestInfoType],
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
|
||||
let requestBody: BatchRequest = requests.map { $0.toSubRequest() }
|
||||
) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> {
|
||||
let responseTypes = requests.map { $0.responseType }
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -206,13 +199,8 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.map { result in
|
||||
result.enumerated()
|
||||
.reduce(into: [:]) { prev, next in
|
||||
prev[requests[next.offset].endpoint] = next.element
|
||||
}
|
||||
}
|
||||
.decoded(as: responseTypes, using: dependencies)
|
||||
.map(requests: requests, toHashMapFor: Endpoint.self)
|
||||
}
|
||||
|
||||
// MARK: - Capabilities
|
||||
|
@ -229,7 +217,7 @@ public enum OpenGroupAPI {
|
|||
server: String,
|
||||
forceBlinded: Bool = false,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Capabilities)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Capabilities), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -240,7 +228,7 @@ public enum OpenGroupAPI {
|
|||
forceBlinded: forceBlinded,
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: Capabilities.self, using: dependencies)
|
||||
}
|
||||
|
||||
// MARK: - Room
|
||||
|
@ -252,7 +240,7 @@ public enum OpenGroupAPI {
|
|||
_ db: Database,
|
||||
server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [Room])> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [Room]), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -262,7 +250,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [Room].self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [Room].self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Returns the details of a single room
|
||||
|
@ -276,7 +264,7 @@ public enum OpenGroupAPI {
|
|||
for roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Room)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Room), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -286,7 +274,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: Room.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: Room.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Polls a room for metadata updates
|
||||
|
@ -304,7 +292,7 @@ public enum OpenGroupAPI {
|
|||
for roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, RoomPollInfo), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -314,7 +302,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: RoomPollInfo.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those
|
||||
|
@ -324,8 +312,8 @@ public enum OpenGroupAPI {
|
|||
for roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), room: (info: OnionRequestResponseInfoType, data: Room))> {
|
||||
let requestResponseType: [BatchRequestInfoType] = [
|
||||
) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (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)
|
||||
BatchRequestInfo(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
|
@ -352,10 +340,10 @@ public enum OpenGroupAPI {
|
|||
requests: requestResponseType,
|
||||
using: dependencies
|
||||
)
|
||||
.map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), room: (OnionRequestResponseInfoType, Room)) in
|
||||
let maybeCapabilities: (info: OnionRequestResponseInfoType, data: Capabilities?)? = response[.capabilities]
|
||||
.map { info, data in (info, (data as? BatchSubResponse<Capabilities>)?.body) }
|
||||
let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response
|
||||
.flatMap { (response: [Endpoint: (ResponseInfoType, Codable?)]) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> in
|
||||
let maybeCapabilities: (info: ResponseInfoType, data: Capabilities?)? = response[.capabilities]
|
||||
.map { info, data in (info, (data as? HTTP.BatchSubResponse<Capabilities>)?.body) }
|
||||
let maybeRoomResponse: (ResponseInfoType, Codable?)? = response
|
||||
.first(where: { key, _ in
|
||||
switch key {
|
||||
case .room: return true
|
||||
|
@ -372,14 +360,18 @@ public enum OpenGroupAPI {
|
|||
let roomInfo: OnionRequestResponseInfoType = maybeRoom?.info,
|
||||
let room: Room = maybeRoom?.data
|
||||
else {
|
||||
throw HTTP.Error.parsingFailed
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return (
|
||||
(capabilitiesInfo, capabilities),
|
||||
(roomInfo, room)
|
||||
)
|
||||
return Just((
|
||||
capabilities: (info: capabilitiesInfo, data: capabilities),
|
||||
room: (info: roomInfo, data: room)
|
||||
))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those
|
||||
|
@ -388,8 +380,8 @@ public enum OpenGroupAPI {
|
|||
_ db: Database,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(capabilities: (info: OnionRequestResponseInfoType, data: Capabilities), rooms: (info: OnionRequestResponseInfoType, data: [Room]))> {
|
||||
let requestResponseType: [BatchRequestInfoType] = [
|
||||
) -> 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)
|
||||
BatchRequestInfo(
|
||||
request: Request<NoBody, Endpoint>(
|
||||
|
@ -416,10 +408,10 @@ public enum OpenGroupAPI {
|
|||
requests: requestResponseType,
|
||||
using: dependencies
|
||||
)
|
||||
.map { (response: [Endpoint: (OnionRequestResponseInfoType, Codable?)]) -> (capabilities: (OnionRequestResponseInfoType, Capabilities), rooms: (OnionRequestResponseInfoType, [Room])) in
|
||||
let maybeCapabilities: (info: OnionRequestResponseInfoType, data: Capabilities?)? = response[.capabilities]
|
||||
.map { info, data in (info, (data as? BatchSubResponse<Capabilities>)?.body) }
|
||||
let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response
|
||||
.flatMap { (response: [Endpoint: (ResponseInfoType, Codable?)]) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> in
|
||||
let maybeCapabilities: (info: ResponseInfoType, data: Capabilities?)? = response[.capabilities]
|
||||
.map { info, data in (info, (data as? HTTP.BatchSubResponse<Capabilities>)?.body) }
|
||||
let maybeRoomResponse: (ResponseInfoType, Codable?)? = response
|
||||
.first(where: { key, _ in
|
||||
switch key {
|
||||
case .rooms: return true
|
||||
|
@ -436,14 +428,18 @@ public enum OpenGroupAPI {
|
|||
let roomsInfo: OnionRequestResponseInfoType = maybeRooms?.info,
|
||||
let rooms: [Room] = maybeRooms?.data
|
||||
else {
|
||||
throw HTTP.Error.parsingFailed
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return (
|
||||
(capabilitiesInfo, capabilities),
|
||||
(roomsInfo, rooms)
|
||||
)
|
||||
return Just((
|
||||
capabilities: (info: capabilitiesInfo, data: capabilities),
|
||||
rooms: (info: roomsInfo, data: rooms)
|
||||
))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
@ -458,9 +454,10 @@ public enum OpenGroupAPI {
|
|||
whisperMods: Bool,
|
||||
fileIds: [String]?,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Message)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Message), Error> {
|
||||
guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else {
|
||||
return Promise(error: OpenGroupAPIError.signingFailed)
|
||||
return Fail(error: OpenGroupAPIError.signingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -480,7 +477,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: Message.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Returns a single message by ID
|
||||
|
@ -490,7 +487,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Message)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Message), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -500,7 +497,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: Message.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Edits a message, replacing its existing content with new content and a new signature
|
||||
|
@ -514,9 +511,10 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else {
|
||||
return Promise(error: OpenGroupAPIError.signingFailed)
|
||||
return Fail(error: OpenGroupAPIError.signingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -542,7 +540,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -564,7 +562,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [Message])> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [Message]), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -574,7 +572,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [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
|
||||
|
@ -587,7 +585,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [Message])> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [Message]), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -597,7 +595,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [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
|
||||
|
@ -610,7 +608,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [Message])> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [Message]), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -624,7 +622,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [Message].self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [Message].self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server
|
||||
|
@ -646,7 +644,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -668,11 +666,12 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<OnionRequestResponseInfoType> {
|
||||
) -> AnyPublisher<ResponseInfoType, Error> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
return Fail(error: OpenGroupAPIError.invalidEmoji)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -686,6 +685,7 @@ public enum OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, _ in responseInfo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public static func reactionAdd(
|
||||
|
@ -695,11 +695,12 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionAddResponse)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, ReactionAddResponse), Error> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
return Fail(error: OpenGroupAPIError.invalidEmoji)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -712,7 +713,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionAddResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: ReactionAddResponse.self, using: dependencies)
|
||||
}
|
||||
|
||||
public static func reactionDelete(
|
||||
|
@ -722,11 +723,12 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveResponse)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, ReactionRemoveResponse), Error> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
return Fail(error: OpenGroupAPIError.invalidEmoji)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -739,7 +741,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionRemoveResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: ReactionRemoveResponse.self, using: dependencies)
|
||||
}
|
||||
|
||||
public static func reactionDeleteAll(
|
||||
|
@ -749,11 +751,12 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveAllResponse)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, ReactionRemoveAllResponse), Error> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
return Fail(error: OpenGroupAPIError.invalidEmoji)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -766,7 +769,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionRemoveAllResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: ReactionRemoveAllResponse.self, using: dependencies)
|
||||
}
|
||||
|
||||
// MARK: - Pinning
|
||||
|
@ -787,7 +790,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<OnionRequestResponseInfoType> {
|
||||
) -> AnyPublisher<ResponseInfoType, Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -799,6 +802,7 @@ public enum OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, _ in responseInfo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Remove a message from this room's pinned message list
|
||||
|
@ -810,7 +814,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<OnionRequestResponseInfoType> {
|
||||
) -> AnyPublisher<ResponseInfoType, Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -822,6 +826,7 @@ public enum OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, _ in responseInfo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Removes _all_ pinned messages from this room
|
||||
|
@ -832,7 +837,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<OnionRequestResponseInfoType> {
|
||||
) -> AnyPublisher<ResponseInfoType, Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -844,6 +849,7 @@ public enum OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, _ in responseInfo }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
@ -855,7 +861,7 @@ public enum OpenGroupAPI {
|
|||
to roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, FileUploadResponse), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -873,7 +879,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: FileUploadResponse.self, using: dependencies)
|
||||
}
|
||||
|
||||
public static func downloadFile(
|
||||
|
@ -882,7 +888,7 @@ public enum OpenGroupAPI {
|
|||
from roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -892,11 +898,17 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, maybeData in
|
||||
guard let data: Data = maybeData else { throw HTTP.Error.parsingFailed }
|
||||
.flatMap { responseInfo, maybeData -> AnyPublisher<(ResponseInfoType, Data), Error> in
|
||||
guard let data: Data = maybeData else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return (responseInfo, data)
|
||||
return Just((responseInfo, data))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Inbox/Outbox (Message Requests)
|
||||
|
@ -911,7 +923,7 @@ public enum OpenGroupAPI {
|
|||
_ db: Database,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -921,7 +933,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [DirectMessage]?.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages
|
||||
|
@ -935,7 +947,7 @@ public enum OpenGroupAPI {
|
|||
id: Int64,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -945,7 +957,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [DirectMessage]?.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Delivers a direct message to a user via their blinded Session ID
|
||||
|
@ -957,7 +969,7 @@ public enum OpenGroupAPI {
|
|||
toInboxFor blindedSessionId: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, SendDirectMessageResponse), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -971,7 +983,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: SendDirectMessageResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: SendDirectMessageResponse.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Retrieves all of the user's sent DMs (up to limit)
|
||||
|
@ -984,7 +996,7 @@ public enum OpenGroupAPI {
|
|||
_ db: Database,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -994,7 +1006,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [DirectMessage]?.self, using: dependencies)
|
||||
}
|
||||
|
||||
/// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages
|
||||
|
@ -1008,7 +1020,7 @@ public enum OpenGroupAPI {
|
|||
id: Int64,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, [DirectMessage]?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -1018,7 +1030,7 @@ public enum OpenGroupAPI {
|
|||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
.decoded(as: [DirectMessage]?.self, using: dependencies)
|
||||
}
|
||||
|
||||
// MARK: - Users
|
||||
|
@ -1061,7 +1073,7 @@ public enum OpenGroupAPI {
|
|||
from roomTokens: [String]? = nil,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -1109,7 +1121,7 @@ public enum OpenGroupAPI {
|
|||
from roomTokens: [String]?,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
|
@ -1186,9 +1198,10 @@ public enum OpenGroupAPI {
|
|||
for roomTokens: [String]?,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else {
|
||||
return Promise(error: HTTP.Error.generic)
|
||||
return Fail(error: HTTPError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
|
@ -1218,7 +1231,7 @@ public enum OpenGroupAPI {
|
|||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<[OnionRequestResponseInfoType]> {
|
||||
) -> AnyPublisher<[ResponseInfoType], Error> {
|
||||
let banRequestBody: UserBanRequest = UserBanRequest(
|
||||
rooms: [roomToken],
|
||||
global: nil,
|
||||
|
@ -1252,6 +1265,7 @@ public enum OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
.map { $0.values.map { responseInfo, _ in responseInfo } }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
@ -1392,14 +1406,15 @@ public enum OpenGroupAPI {
|
|||
request: Request<T, Endpoint>,
|
||||
forceBlinded: Bool = false,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
let urlRequest: URLRequest
|
||||
|
||||
do {
|
||||
urlRequest = try request.generateUrlRequest()
|
||||
}
|
||||
catch {
|
||||
return Promise(error: error)
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let maybePublicKey: String? = try? OpenGroup
|
||||
|
@ -1408,13 +1423,20 @@ public enum OpenGroupAPI {
|
|||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
|
||||
guard let publicKey: String = maybePublicKey else { return Promise(error: OpenGroupAPIError.noPublicKey) }
|
||||
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 Promise(error: OpenGroupAPIError.signingFailed)
|
||||
return Fail(error: OpenGroupAPIError.signingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey)
|
||||
return dependencies.onionApi
|
||||
.sendOnionRequest(signedRequest, to: request.server, with: publicKey)
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import Sodium
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
@ -10,8 +10,8 @@ import SessionSnodeKit
|
|||
// MARK: - OGMCacheType
|
||||
|
||||
public protocol OGMCacheType {
|
||||
var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? { get set }
|
||||
var groupImagePromises: [String: Promise<Data>] { get set }
|
||||
var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? { get set }
|
||||
var groupImagePublishers: [String: AnyPublisher<Data, Error>] { get set }
|
||||
|
||||
var pollers: [String: OpenGroupAPI.Poller] { get set }
|
||||
var isPolling: Bool { get set }
|
||||
|
@ -31,8 +31,8 @@ public final class OpenGroupManager: NSObject {
|
|||
// MARK: - Cache
|
||||
|
||||
public class Cache: OGMCacheType {
|
||||
public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>?
|
||||
public var groupImagePromises: [String: Promise<Data>] = [:]
|
||||
public var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>?
|
||||
public var groupImagePublishers: [String: AnyPublisher<Data, Error>] = [:]
|
||||
|
||||
public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server
|
||||
public var isPolling: Bool = false
|
||||
|
@ -199,11 +199,20 @@ public final class OpenGroupManager: NSObject {
|
|||
return hasExistingThread
|
||||
}
|
||||
|
||||
public func add(_ db: Database, roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, dependencies: OGMDependencies = OGMDependencies()) -> Promise<Void> {
|
||||
public func add(
|
||||
_ db: Database,
|
||||
roomToken: String,
|
||||
server: String,
|
||||
publicKey: String,
|
||||
isConfigMessage: Bool,
|
||||
dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
// If we are currently polling for this server and already have a TSGroupThread for this room the do nothing
|
||||
if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) {
|
||||
SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)")
|
||||
return Promise.value(())
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Store the open group information
|
||||
|
@ -237,25 +246,29 @@ public final class OpenGroupManager: NSObject {
|
|||
OpenGroup.Columns.sequenceNumber.set(to: 0)
|
||||
)
|
||||
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
// Note: We don't do this after the db commit as it can fail (resulting in endless loading)
|
||||
OpenGroupAPI.workQueue.async {
|
||||
dependencies.storage
|
||||
.writeAsync { db in
|
||||
// Note: The initial request for room info and it's capabilities should NOT be
|
||||
// authenticated (this is because if the server requires blinding and the auth
|
||||
// headers aren't blinded it will error - these endpoints do support unauthenticated
|
||||
// retrieval so doing so prevents the error)
|
||||
OpenGroupAPI
|
||||
.capabilitiesAndRoom(
|
||||
db,
|
||||
for: roomToken,
|
||||
on: targetServer,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.done(on: OpenGroupAPI.workQueue) { response in
|
||||
return Future<Void, Error> { resolver in
|
||||
OpenGroupAPI.workQueue.async { resolver(Result.success(())) }
|
||||
}
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.flatMap { _ in
|
||||
dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
// Note: The initial request for room info and it's capabilities should NOT be
|
||||
// authenticated (this is because if the server requires blinding and the auth
|
||||
// headers aren't blinded it will error - these endpoints do support unauthenticated
|
||||
// retrieval so doing so prevents the error)
|
||||
OpenGroupAPI
|
||||
.capabilitiesAndRoom(
|
||||
db,
|
||||
for: roomToken,
|
||||
on: targetServer,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
.flatMap { response -> Future<Void, Error> in
|
||||
Future<Void, Error> { resolver in
|
||||
dependencies.storage.write { db in
|
||||
// Store the capabilities first
|
||||
OpenGroupManager.handleCapabilities(
|
||||
|
@ -273,18 +286,21 @@ public final class OpenGroupManager: NSObject {
|
|||
on: targetServer,
|
||||
dependencies: dependencies
|
||||
) {
|
||||
seal.fulfill(())
|
||||
// TODO: Remove the 'Swift.'
|
||||
resolver(Swift.Result.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
|
||||
SNLog("Failed to join open group.")
|
||||
seal.reject(error)
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure: SNLog("Failed to join open group.")
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
return promise
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func delete(_ db: Database, openGroupId: String, dependencies: OGMDependencies = OGMDependencies()) {
|
||||
|
@ -485,24 +501,28 @@ public final class OpenGroupManager: NSObject {
|
|||
openGroup.imageId != imageId
|
||||
)
|
||||
{
|
||||
OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies)
|
||||
.done { data in
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: data))
|
||||
|
||||
OpenGroupManager
|
||||
.roomImage(
|
||||
db,
|
||||
fileId: imageId,
|
||||
for: roomToken,
|
||||
on: server,
|
||||
using: dependencies
|
||||
)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
if waitForImageToComplete {
|
||||
completion?()
|
||||
}
|
||||
},
|
||||
receiveValue: { data in
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: data))
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { _ in
|
||||
if waitForImageToComplete {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
else if waitForImageToComplete {
|
||||
completion?()
|
||||
|
@ -920,90 +940,103 @@ public final class OpenGroupManager: NSObject {
|
|||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
@discardableResult public static func getDefaultRoomsIfNeeded(using dependencies: OGMDependencies = OGMDependencies()) -> Promise<[OpenGroupAPI.Room]> {
|
||||
@discardableResult public static func getDefaultRoomsIfNeeded(
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> AnyPublisher<[OpenGroupAPI.Room], Error> {
|
||||
return Just([]) // TODO: Remove this
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again
|
||||
if let existingPromise: Promise<[OpenGroupAPI.Room]> = dependencies.cache.defaultRoomsPromise {
|
||||
return existingPromise
|
||||
if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher {
|
||||
return existingPublisher
|
||||
}
|
||||
|
||||
let (promise, seal) = Promise<[OpenGroupAPI.Room]>.pending()
|
||||
|
||||
// Try to retrieve the default rooms 8 times
|
||||
attempt(maxRetryCount: 8, recoveringOn: OpenGroupAPI.workQueue) {
|
||||
dependencies.storage.read { db in
|
||||
let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI.capabilitiesAndRooms(
|
||||
db,
|
||||
on: OpenGroupAPI.defaultServer,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
.done(on: OpenGroupAPI.workQueue) { response in
|
||||
dependencies.storage.writeAsync { db in
|
||||
// Store the capabilities first
|
||||
OpenGroupManager.handleCapabilities(
|
||||
db,
|
||||
capabilities: response.capabilities.data,
|
||||
on: OpenGroupAPI.defaultServer
|
||||
)
|
||||
|
||||
// Then the rooms
|
||||
response.rooms.data
|
||||
.compactMap { room -> (String, String)? in
|
||||
// Try to insert an inactive version of the OpenGroup (use 'insert' rather than 'save'
|
||||
// as we want it to fail if the room already exists)
|
||||
do {
|
||||
_ = try OpenGroup(
|
||||
server: OpenGroupAPI.defaultServer,
|
||||
roomToken: room.token,
|
||||
publicKey: OpenGroupAPI.defaultServerPublicKey,
|
||||
isActive: false,
|
||||
name: room.name,
|
||||
roomDescription: room.roomDescription,
|
||||
imageId: room.imageId,
|
||||
imageData: nil,
|
||||
userCount: room.activeUsers,
|
||||
infoUpdates: room.infoUpdates,
|
||||
sequenceNumber: 0,
|
||||
inboxLatestMessageId: 0,
|
||||
outboxLatestMessageId: 0
|
||||
)
|
||||
.inserted(db)
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.retry(8)
|
||||
.map { response in
|
||||
dependencies.storage.writeAsync { db in
|
||||
// Store the capabilities first
|
||||
OpenGroupManager.handleCapabilities(
|
||||
db,
|
||||
capabilities: response.capabilities.data,
|
||||
on: OpenGroupAPI.defaultServer
|
||||
)
|
||||
|
||||
// Then the rooms
|
||||
response.rooms.data
|
||||
.compactMap { room -> (String, String)? in
|
||||
// Try to insert an inactive version of the OpenGroup (use 'insert'
|
||||
// rather than 'save' as we want it to fail if the room already exists)
|
||||
do {
|
||||
_ = try OpenGroup(
|
||||
server: OpenGroupAPI.defaultServer,
|
||||
roomToken: room.token,
|
||||
publicKey: OpenGroupAPI.defaultServerPublicKey,
|
||||
isActive: false,
|
||||
name: room.name,
|
||||
roomDescription: room.roomDescription,
|
||||
imageId: room.imageId,
|
||||
imageData: nil,
|
||||
userCount: room.activeUsers,
|
||||
infoUpdates: room.infoUpdates,
|
||||
sequenceNumber: 0,
|
||||
inboxLatestMessageId: 0,
|
||||
outboxLatestMessageId: 0
|
||||
)
|
||||
.inserted(db)
|
||||
}
|
||||
catch {}
|
||||
|
||||
guard let imageId: String = room.imageId else { return nil }
|
||||
|
||||
return (imageId, room.token)
|
||||
}
|
||||
catch {}
|
||||
|
||||
guard let imageId: String = room.imageId else { return nil }
|
||||
|
||||
return (imageId, room.token)
|
||||
}
|
||||
.forEach { imageId, roomToken in
|
||||
roomImage(
|
||||
db,
|
||||
fileId: imageId,
|
||||
for: roomToken,
|
||||
on: OpenGroupAPI.defaultServer,
|
||||
using: dependencies
|
||||
)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
.forEach { imageId, roomToken in
|
||||
roomImage(
|
||||
db,
|
||||
fileId: imageId,
|
||||
for: roomToken,
|
||||
on: OpenGroupAPI.defaultServer,
|
||||
using: dependencies
|
||||
)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
return response.rooms.data
|
||||
}
|
||||
|
||||
seal.fulfill(response.rooms.data)
|
||||
}
|
||||
.catch(on: OpenGroupAPI.workQueue) { error in
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.defaultRoomsPromise = nil
|
||||
}
|
||||
|
||||
seal.reject(error)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.defaultRoomsPublisher = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.defaultRoomsPromise = promise
|
||||
cache.defaultRoomsPublisher = publisher
|
||||
}
|
||||
|
||||
return promise
|
||||
// Hold on to the publisher until it has completed at least once
|
||||
publisher.sinkUntilComplete()
|
||||
|
||||
return publisher
|
||||
}
|
||||
|
||||
public static func roomImage(
|
||||
|
@ -1012,7 +1045,7 @@ public final class OpenGroupManager: NSObject {
|
|||
for roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> Promise<Data> {
|
||||
) -> AnyPublisher<Data, Error> {
|
||||
// Normally the image for a given group is stored with the group thread, so it's only
|
||||
// fetched once. However, on the join open group screen we show images for groups the
|
||||
// user * hasn't * joined yet. We don't want to re-fetch these images every time the
|
||||
|
@ -1036,48 +1069,52 @@ public final class OpenGroupManager: NSObject {
|
|||
.filter(id: threadId)
|
||||
.asRequest(of: Data.self)
|
||||
.fetchOne(db)
|
||||
{ return Promise.value(data) }
|
||||
|
||||
if let promise = dependencies.cache.groupImagePromises[threadId] {
|
||||
return promise
|
||||
{
|
||||
return Just(data)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let (promise, seal) = Promise<Data>.pending()
|
||||
if let publisher: AnyPublisher<Data, Error> = dependencies.cache.groupImagePublishers[threadId] {
|
||||
return publisher
|
||||
}
|
||||
|
||||
// Trigger the download on a background queue
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
dependencies.storage
|
||||
.read { db in
|
||||
OpenGroupAPI
|
||||
.downloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: roomToken,
|
||||
on: server,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.done { _, imageData in
|
||||
if server.lowercased() == OpenGroupAPI.defaultServer {
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
|
||||
}
|
||||
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
|
||||
let publisher: AnyPublisher<Data, Error> = dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI
|
||||
.downloadFile(
|
||||
db,
|
||||
fileId: fileId,
|
||||
from: roomToken,
|
||||
on: server,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
.subscribe(on: DispatchQueue.global(qos: .background))
|
||||
.map { _, imageData in
|
||||
if server.lowercased() == OpenGroupAPI.defaultServer {
|
||||
dependencies.storage.write { db in
|
||||
_ = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
|
||||
}
|
||||
|
||||
seal.fulfill(imageData)
|
||||
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
|
||||
}
|
||||
.catch { seal.reject($0) }
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.groupImagePromises[threadId] = promise
|
||||
cache.groupImagePublishers[threadId] = publisher
|
||||
}
|
||||
|
||||
return promise
|
||||
// Hold on to the publisher until it has completed at least once
|
||||
publisher.sinkUntilComplete()
|
||||
|
||||
return publisher
|
||||
}
|
||||
|
||||
public static func parseOpenGroup(from string: String) -> (room: String, server: String, publicKey: String)? {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import Sodium
|
||||
import SessionUtilitiesKit
|
||||
|
|
|
@ -166,7 +166,7 @@ extension MessageReceiver {
|
|||
if let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: openGroupURL) {
|
||||
OpenGroupManager.shared
|
||||
.add(db, roomToken: room, server: server, publicKey: publicKey, isConfigMessage: true)
|
||||
.retainUntilComplete()
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -167,6 +168,6 @@ extension MessageReceiver {
|
|||
// Force a config sync to ensure all devices know the contact approval state if desired
|
||||
guard forceConfigSync else { return }
|
||||
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,12 @@ extension MessageReceiver {
|
|||
}
|
||||
|
||||
if author == message.sender, let serverHash: String = interaction.serverHash {
|
||||
SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete()
|
||||
SnodeAPI
|
||||
.deleteMessages(
|
||||
publicKey: author,
|
||||
serverHashes: [serverHash]
|
||||
)
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
switch (interaction.variant, (author == message.sender)) {
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import Sodium
|
||||
import Curve25519Kit
|
||||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension MessageSender {
|
||||
public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:])
|
||||
|
||||
public static func createClosedGroup(_ db: Database, name: String, members: Set<String>) throws -> Promise<SessionThread> {
|
||||
public static func createClosedGroup(
|
||||
_ db: Database,
|
||||
name: String,
|
||||
members: Set<String>
|
||||
) -> AnyPublisher<SessionThread, Error> {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
var members: Set<String> = members
|
||||
|
||||
|
@ -25,98 +29,114 @@ extension MessageSender {
|
|||
let admins = [ userPublicKey ]
|
||||
let adminsAsData = admins.map { Data(hex: $0) }
|
||||
let formationTimestamp: TimeInterval = Date().timeIntervalSince1970
|
||||
let thread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup)
|
||||
try ClosedGroup(
|
||||
threadId: groupPublicKey,
|
||||
name: name,
|
||||
formationTimestamp: formationTimestamp
|
||||
).insert(db)
|
||||
let thread: SessionThread
|
||||
let memberSendData: [MessageSender.PreparedSendData]
|
||||
|
||||
try admins.forEach { adminId in
|
||||
try GroupMember(
|
||||
groupId: groupPublicKey,
|
||||
profileId: adminId,
|
||||
role: .admin,
|
||||
isHidden: false
|
||||
do {
|
||||
// Create the relevant objects in the database
|
||||
thread = try SessionThread
|
||||
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup)
|
||||
try ClosedGroup(
|
||||
threadId: groupPublicKey,
|
||||
name: name,
|
||||
formationTimestamp: formationTimestamp
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// Send a closed group update message to all members individually
|
||||
var promises: [Promise<Void>] = []
|
||||
|
||||
try members.forEach { memberId in
|
||||
try GroupMember(
|
||||
groupId: groupPublicKey,
|
||||
profileId: memberId,
|
||||
role: .standard,
|
||||
isHidden: false
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try members.forEach { memberId in
|
||||
let contactThread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(db, id: memberId, variant: .contact)
|
||||
|
||||
// Sending this non-durably is okay because we show a loader to the user. If they
|
||||
// close the app while the loader is still showing, it's within expectation that
|
||||
// the group creation might be incomplete.
|
||||
promises.append(
|
||||
try MessageSender.sendNonDurably(
|
||||
db,
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .new(
|
||||
publicKey: Data(hex: groupPublicKey),
|
||||
name: name,
|
||||
encryptionKeyPair: Box.KeyPair(
|
||||
publicKey: encryptionKeyPair.publicKey.bytes,
|
||||
secretKey: encryptionKeyPair.privateKey.bytes
|
||||
// Store the key pair
|
||||
try ClosedGroupKeyPair(
|
||||
threadId: groupPublicKey,
|
||||
publicKey: encryptionKeyPair.publicKey,
|
||||
secretKey: encryptionKeyPair.privateKey,
|
||||
receivedTimestamp: Date().timeIntervalSince1970
|
||||
).insert(db)
|
||||
|
||||
// Create the member objects
|
||||
try admins.forEach { adminId in
|
||||
try GroupMember(
|
||||
groupId: groupPublicKey,
|
||||
profileId: adminId,
|
||||
role: .admin,
|
||||
isHidden: false
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try members.forEach { memberId in
|
||||
try GroupMember(
|
||||
groupId: groupPublicKey,
|
||||
profileId: memberId,
|
||||
role: .standard,
|
||||
isHidden: false
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
// Notify the user
|
||||
//
|
||||
// Note: Intentionally don't want a 'serverHash' for closed group creation
|
||||
_ = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupCreated,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
memberSendData = try members
|
||||
.map { memberId -> MessageSender.PreparedSendData in
|
||||
try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
kind: .new(
|
||||
publicKey: Data(hex: groupPublicKey),
|
||||
name: name,
|
||||
encryptionKeyPair: Box.KeyPair(
|
||||
publicKey: encryptionKeyPair.publicKey.bytes,
|
||||
secretKey: encryptionKeyPair.privateKey.bytes
|
||||
),
|
||||
members: membersAsData,
|
||||
admins: adminsAsData,
|
||||
expirationTimer: 0
|
||||
),
|
||||
members: membersAsData,
|
||||
admins: adminsAsData,
|
||||
expirationTimer: 0
|
||||
// Note: We set this here to ensure the value matches
|
||||
// the 'ClosedGroup' object we created
|
||||
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
||||
),
|
||||
// Note: We set this here to ensure the value matches the 'ClosedGroup'
|
||||
// object we created
|
||||
sentTimestampMs: UInt64(floor(formationTimestamp * 1000))
|
||||
),
|
||||
interactionId: nil,
|
||||
in: contactThread
|
||||
)
|
||||
)
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Store the key pair
|
||||
try ClosedGroupKeyPair(
|
||||
threadId: groupPublicKey,
|
||||
publicKey: encryptionKeyPair.publicKey,
|
||||
secretKey: encryptionKeyPair.privateKey,
|
||||
receivedTimestamp: Date().timeIntervalSince1970
|
||||
).insert(db)
|
||||
|
||||
// Notify the PN server
|
||||
promises.append(
|
||||
PushNotificationAPI.performOperation(
|
||||
.subscribe,
|
||||
for: groupPublicKey,
|
||||
publicKey: userPublicKey
|
||||
return Publishers
|
||||
.MergeMany(
|
||||
// Send a closed group update message to all members individually
|
||||
memberSendData
|
||||
.map { MessageSender.sendImmediate(data: $0) }
|
||||
.appending(
|
||||
// Notify the PN server
|
||||
PushNotificationAPI.performOperation(
|
||||
.subscribe,
|
||||
for: groupPublicKey,
|
||||
publicKey: userPublicKey
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Notify the user
|
||||
//
|
||||
// Note: Intentionally don't want a 'serverHash' for closed group creation
|
||||
_ = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupCreated,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
// Start polling
|
||||
ClosedGroupPoller.shared.startPolling(for: groupPublicKey)
|
||||
|
||||
return when(fulfilled: promises).map2 { thread }
|
||||
.collect()
|
||||
.map { _ in thread }
|
||||
.eraseToAnyPublisher()
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished:
|
||||
// Start polling
|
||||
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Generates and distributes a new encryption key pair for the group with the given closed group. This sends an
|
||||
|
@ -132,34 +152,39 @@ extension MessageSender {
|
|||
allGroupMembers: [GroupMember],
|
||||
closedGroup: ClosedGroup,
|
||||
thread: SessionThread
|
||||
) throws -> Promise<Void> {
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
// Generate the new encryption key pair
|
||||
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
||||
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
|
||||
threadId: closedGroup.threadId,
|
||||
publicKey: legacyNewKeyPair.publicKey,
|
||||
secretKey: legacyNewKeyPair.privateKey,
|
||||
receivedTimestamp: Date().timeIntervalSince1970
|
||||
)
|
||||
|
||||
// Distribute it
|
||||
let proto = try SNProtoKeyPair.builder(
|
||||
publicKey: newKeyPair.publicKey,
|
||||
privateKey: newKeyPair.secretKey
|
||||
).build()
|
||||
let plaintext = try proto.serializedData()
|
||||
|
||||
distributingKeyPairs.mutate {
|
||||
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
||||
.appending(newKeyPair)
|
||||
}
|
||||
let newKeyPair: ClosedGroupKeyPair
|
||||
let sendData: MessageSender.PreparedSendData
|
||||
|
||||
do {
|
||||
return try MessageSender
|
||||
.sendNonDurably(
|
||||
// Generate the new encryption key pair
|
||||
let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair()
|
||||
newKeyPair = ClosedGroupKeyPair(
|
||||
threadId: closedGroup.threadId,
|
||||
publicKey: legacyNewKeyPair.publicKey,
|
||||
secretKey: legacyNewKeyPair.privateKey,
|
||||
receivedTimestamp: Date().timeIntervalSince1970
|
||||
)
|
||||
|
||||
// Distribute it
|
||||
let proto = try SNProtoKeyPair.builder(
|
||||
publicKey: newKeyPair.publicKey,
|
||||
privateKey: newKeyPair.secretKey
|
||||
).build()
|
||||
let plaintext = try proto.serializedData()
|
||||
|
||||
distributingKeyPairs.mutate {
|
||||
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
||||
.appending(newKeyPair)
|
||||
}
|
||||
|
||||
sendData = try MessageSender
|
||||
.preparedSendData(
|
||||
db,
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .encryptionKeyPair(
|
||||
|
@ -175,27 +200,35 @@ extension MessageSender {
|
|||
}
|
||||
)
|
||||
),
|
||||
interactionId: nil,
|
||||
in: thread
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: nil
|
||||
)
|
||||
.done {
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return MessageSender.sendImmediate(data: sendData)
|
||||
.map { _ in newKeyPair }
|
||||
.eraseToAnyPublisher()
|
||||
.handleEvents(
|
||||
receiveOutput: { newKeyPair in
|
||||
/// Store it **after** having sent out the message to the group
|
||||
Storage.shared.write { db in
|
||||
try newKeyPair.insert(db)
|
||||
|
||||
distributingKeyPairs.mutate {
|
||||
if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) {
|
||||
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
||||
.removing(index: index)
|
||||
}
|
||||
}
|
||||
|
||||
distributingKeyPairs.mutate {
|
||||
if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) {
|
||||
$0[closedGroup.id] = ($0[closedGroup.id] ?? [])
|
||||
.removing(index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { _ in }
|
||||
}
|
||||
catch {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
}
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public static func update(
|
||||
|
@ -203,51 +236,59 @@ extension MessageSender {
|
|||
groupPublicKey: String,
|
||||
with members: Set<String>,
|
||||
name: String
|
||||
) throws -> Promise<Void> {
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
// Get the group, check preconditions & prepare
|
||||
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
|
||||
SNLog("Can't update nonexistent closed group.")
|
||||
return Promise(error: MessageSenderError.noThread)
|
||||
return Fail(error: MessageSenderError.noThread)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
// Update name if needed
|
||||
if name != closedGroup.name {
|
||||
// Update the group
|
||||
_ = try ClosedGroup
|
||||
.filter(id: closedGroup.id)
|
||||
.updateAll(db, ClosedGroup.Columns.name.set(to: name))
|
||||
|
||||
// Notify the user
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
body: ClosedGroupControlMessage.Kind
|
||||
.nameChange(name: name)
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
||||
// Send the update to the group
|
||||
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name))
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: closedGroupControlMessage,
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
)
|
||||
do {
|
||||
// Update name if needed
|
||||
if name != closedGroup.name {
|
||||
// Update the group
|
||||
_ = try ClosedGroup
|
||||
.filter(id: closedGroup.id)
|
||||
.updateAll(db, ClosedGroup.Columns.name.set(to: name))
|
||||
|
||||
// Notify the user
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
body: LegacyClosedGroupControlMessage.Kind
|
||||
.nameChange(name: name)
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
||||
// Send the update to the group
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(kind: .nameChange(name: name)),
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Retrieve member info
|
||||
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let standardAndZombieMemberIds: [String] = allGroupMembers
|
||||
|
@ -268,7 +309,8 @@ extension MessageSender {
|
|||
)
|
||||
}
|
||||
catch {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -287,11 +329,14 @@ extension MessageSender {
|
|||
)
|
||||
}
|
||||
catch {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.value(())
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
|
@ -395,7 +440,7 @@ extension MessageSender {
|
|||
allGroupMembers: [GroupMember],
|
||||
closedGroup: ClosedGroup,
|
||||
thread: SessionThread
|
||||
) throws -> Promise<Void> {
|
||||
) throws -> AnyPublisher<Void, Error> {
|
||||
guard !removedMembers.contains(userPublicKey) else {
|
||||
SNLog("Invalid closed group update.")
|
||||
throw MessageSenderError.invalidClosedGroupUpdate
|
||||
|
@ -443,19 +488,22 @@ extension MessageSender {
|
|||
}
|
||||
|
||||
// Send the update to the group and generate + distribute a new encryption key pair
|
||||
let promise = try MessageSender
|
||||
.sendNonDurably(
|
||||
db,
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .membersRemoved(
|
||||
members: removedMembers.map { Data(hex: $0) }
|
||||
return MessageSender
|
||||
.sendImmediate(
|
||||
data: try MessageSender
|
||||
.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
kind: .membersRemoved(
|
||||
members: removedMembers.map { Data(hex: $0) }
|
||||
)
|
||||
),
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: interactionId
|
||||
)
|
||||
),
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
)
|
||||
.map { _ in
|
||||
try generateAndSendNewEncryptionKeyPair(
|
||||
.flatMap { _ -> AnyPublisher<Void, Error> in
|
||||
generateAndSendNewEncryptionKeyPair(
|
||||
db,
|
||||
targetMembers: members,
|
||||
userPublicKey: userPublicKey,
|
||||
|
@ -464,9 +512,7 @@ extension MessageSender {
|
|||
thread: thread
|
||||
)
|
||||
}
|
||||
.map { _ in }
|
||||
|
||||
return promise
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the
|
||||
|
@ -477,104 +523,93 @@ extension MessageSender {
|
|||
/// unregisters from push notifications.
|
||||
///
|
||||
/// The returned promise is fulfilled when the `MEMBER_LEFT` message has been sent to the group.
|
||||
public static func leave(_ db: Database, groupPublicKey: String) throws -> Promise<Void> {
|
||||
public static func leave(
|
||||
_ db: Database,
|
||||
groupPublicKey: String
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
|
||||
SNLog("Can't leave nonexistent closed group.")
|
||||
return Promise(error: MessageSenderError.noThread)
|
||||
return Fail(error: MessageSenderError.noThread)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else {
|
||||
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
guard thread.closedGroup.isNotEmpty(db) else {
|
||||
return Fail(error: MessageSenderError.invalidClosedGroupUpdate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let sendData: MessageSender.PreparedSendData
|
||||
|
||||
// Notify the user
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupCurrentUserLeft,
|
||||
body: ClosedGroupControlMessage.Kind
|
||||
.memberLeft
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else {
|
||||
throw StorageError.objectNotSaved
|
||||
}
|
||||
|
||||
// Send the update to the group
|
||||
let promise = try MessageSender
|
||||
.sendNonDurably(
|
||||
db,
|
||||
message: ClosedGroupControlMessage(
|
||||
kind: .memberLeft
|
||||
),
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
)
|
||||
.done {
|
||||
// Remove the group from the database and unsubscribe from PNs
|
||||
ClosedGroupPoller.shared.stopPolling(for: groupPublicKey)
|
||||
|
||||
Storage.shared.write { db in
|
||||
try closedGroup
|
||||
.keyPairs
|
||||
.deleteAll(db)
|
||||
|
||||
let _ = PushNotificationAPI.performOperation(
|
||||
.unsubscribe,
|
||||
for: groupPublicKey,
|
||||
publicKey: userPublicKey
|
||||
)
|
||||
}
|
||||
do {
|
||||
// Notify the user
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: userPublicKey,
|
||||
variant: .infoClosedGroupCurrentUserLeft,
|
||||
body: LegacyClosedGroupControlMessage.Kind
|
||||
.memberLeft
|
||||
.infoMessage(db, sender: userPublicKey),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
).inserted(db)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else {
|
||||
return Fail(error: StorageError.objectNotSaved)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.map { _ in }
|
||||
|
||||
// Update the group (if the admin leaves the group is disbanded)
|
||||
let wasAdminUser: Bool = try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
|
||||
.isNotEmpty(db)
|
||||
|
||||
if wasAdminUser {
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
else {
|
||||
try GroupMember
|
||||
|
||||
// Send the update to the group
|
||||
sendData = try MessageSender
|
||||
.preparedSendData(
|
||||
db,
|
||||
message: LegacyClosedGroupControlMessage(
|
||||
kind: .memberLeft
|
||||
),
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: interactionId
|
||||
)
|
||||
|
||||
// Update the group (if the admin leaves the group is disbanded)
|
||||
let wasAdminUser: Bool = GroupMember
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
.deleteAll(db)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
|
||||
.isNotEmpty(db)
|
||||
|
||||
if wasAdminUser {
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
else {
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Return
|
||||
return promise
|
||||
return MessageSender
|
||||
.sendImmediate(data: sendData)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished: try? ClosedGroup.removeKeysAndUnsubscribe(threadId: groupPublicKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/*
|
||||
public static func requestEncryptionKeyPair(for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) throws {
|
||||
#if DEBUG
|
||||
preconditionFailure("Shouldn't currently be in use.")
|
||||
#endif
|
||||
// Get the group, check preconditions & prepare
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
|
||||
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
|
||||
SNLog("Can't request encryption key pair for nonexistent closed group.")
|
||||
throw Error.noThread
|
||||
}
|
||||
let group = thread.groupModel
|
||||
guard group.groupMemberIds.contains(getUserHexEncodedPublicKey()) else { return }
|
||||
// Send the request to the group
|
||||
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPairRequest)
|
||||
MessageSender.send(closedGroupControlMessage, in: thread, using: transaction)
|
||||
}
|
||||
*/
|
||||
|
||||
public static func sendLatestEncryptionKeyPair(_ db: Database, to publicKey: String, for groupPublicKey: String) {
|
||||
public static func sendLatestEncryptionKeyPair(
|
||||
_ db: Database,
|
||||
to publicKey: String,
|
||||
for groupPublicKey: String
|
||||
) {
|
||||
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
|
||||
return SNLog("Couldn't send key pair for nonexistent closed group.")
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
|
@ -90,6 +91,23 @@ extension MessageSender {
|
|||
)
|
||||
}
|
||||
|
||||
public static func preparedSendData(
|
||||
_ db: Database,
|
||||
interaction: Interaction,
|
||||
in thread: SessionThread
|
||||
) throws -> PreparedSendData {
|
||||
// Only 'VisibleMessage' types can be sent via this method
|
||||
guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage }
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
||||
return try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
interactionId: interactionId
|
||||
)
|
||||
}
|
||||
|
||||
public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise<Void> {
|
||||
return sendNonDurably(
|
||||
db,
|
||||
|
@ -140,10 +158,12 @@ extension MessageSender {
|
|||
on: openGroup.server
|
||||
)
|
||||
.map { _, response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return FileServerAPI.upload(data)
|
||||
.map { response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
},
|
||||
encrypt: (openGroup == nil),
|
||||
success: { fileId in seal.fulfill(fileId) },
|
||||
|
@ -155,46 +175,146 @@ extension MessageSender {
|
|||
}
|
||||
|
||||
// Once the attachments are processed then send the message
|
||||
return when(resolved: attachmentUploadPromises)
|
||||
.then { results -> Promise<Void> in
|
||||
let errors: [Error] = results
|
||||
.compactMap { result -> Error? in
|
||||
if case .rejected(let error) = result { return error }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if let error: Error = errors.first { return Promise(error: error) }
|
||||
|
||||
return Storage.shared.writeAsync { db in
|
||||
let fileIds: [String] = results
|
||||
.compactMap { result -> String? in
|
||||
if case .fulfilled(let value) = result { return value }
|
||||
|
||||
return nil
|
||||
}
|
||||
// TODO: Need to update all usages of this method
|
||||
preconditionFailure()
|
||||
// return when(resolved: attachmentUploadPromises)
|
||||
// .then { results -> Promise<Void> in
|
||||
// let errors: [Error] = results
|
||||
// .compactMap { result -> Error? in
|
||||
// if case .rejected(let error) = result { return error }
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// if let error: Error = errors.first { return Promise(error: error) }
|
||||
//
|
||||
// return Storage.shared.writeAsync { db in
|
||||
// let fileIds: [String] = results
|
||||
// .compactMap { result -> String? in
|
||||
// if case .fulfilled(let value) = result { return value }
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return try MessageSender.sendImmediate(
|
||||
// db,
|
||||
// message: message,
|
||||
// to: destination
|
||||
// .with(fileIds: fileIds),
|
||||
// interactionId: interactionId
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
public static func performUploadsIfNeeded(
|
||||
preparedSendData: PreparedSendData
|
||||
) -> AnyPublisher<PreparedSendData, Error> {
|
||||
// We need an interactionId in order for a message to have uploads
|
||||
guard let interactionId: Int64 = preparedSendData.interactionId else {
|
||||
return Just(preparedSendData)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Ensure we have the rest of the required data
|
||||
guard let destination: Message.Destination = preparedSendData.destination else {
|
||||
return Fail<PreparedSendData, Error>(error: MessageSenderError.invalidMessage)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let threadId: String = {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .openGroup(let roomToken, let server, _, _, _):
|
||||
return OpenGroup.idFor(roomToken: roomToken, server: server)
|
||||
|
||||
return try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: destination
|
||||
.with(fileIds: fileIds),
|
||||
interactionId: interactionId
|
||||
)
|
||||
}
|
||||
case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey
|
||||
}
|
||||
}()
|
||||
let fileIdPublisher: AnyPublisher<[String?], Error> = Storage.shared
|
||||
.write { db -> AnyPublisher<[String?], Error>? in
|
||||
let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment
|
||||
.stateInfo(interactionId: interactionId, state: .uploading)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
// If there is no attachment data then just return early
|
||||
guard !attachmentStateInfo.isEmpty else { return nil }
|
||||
|
||||
// Otherwise we need to generate the upload requests
|
||||
let openGroup: OpenGroup? = try? OpenGroup.fetchOne(db, id: threadId)
|
||||
|
||||
return Publishers
|
||||
.MergeMany(
|
||||
(try? Attachment
|
||||
.filter(ids: attachmentStateInfo.map { $0.attachmentId })
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
.map { attachment -> AnyPublisher<String?, Error> in
|
||||
Future { resolver in
|
||||
attachment.upload(
|
||||
db,
|
||||
queue: DispatchQueue.global(qos: .userInitiated),
|
||||
using: { db, data in
|
||||
if let openGroup: OpenGroup = openGroup {
|
||||
return OpenGroupAPI
|
||||
.uploadFile(
|
||||
db,
|
||||
bytes: data.bytes,
|
||||
to: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return FileServerAPI.upload(data)
|
||||
.map { response -> String in response.id }
|
||||
.eraseToAnyPublisher()
|
||||
},
|
||||
encrypt: (openGroup == nil),
|
||||
success: { fileId in resolver(Swift.Result.success(fileId)) },
|
||||
failure: { resolver(Swift.Result.failure($0)) }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
)
|
||||
.collect()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.defaulting(
|
||||
to: Just<[String?]>([])
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
)
|
||||
|
||||
return fileIdPublisher
|
||||
.map { results in
|
||||
// Once the attachments are processed then update the PreparedSendData with
|
||||
// the fileIds associated to the message
|
||||
let fileIds: [String] = results.compactMap { result -> String? in result }
|
||||
|
||||
return preparedSendData.with(fileIds: fileIds)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block
|
||||
/// it will throw a "re-entrant" fatal error when attempting to write again
|
||||
public static func syncConfiguration(_ db: Database, forceSyncNow: Bool = true) throws -> Promise<Void> {
|
||||
public static func syncConfiguration(
|
||||
_ db: Database,
|
||||
forceSyncNow: Bool = true
|
||||
) throws -> AnyPublisher<Void, Error> {
|
||||
// If we don't have a userKeyPair yet then there is no need to sync the configuration
|
||||
// as the user doesn't exist yet (this will get triggered on the first launch of a
|
||||
// fresh install due to the migrations getting run)
|
||||
guard
|
||||
Identity.userExists(db),
|
||||
let ed25519SecretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey
|
||||
else { return Promise(error: StorageError.generic) }
|
||||
guard Identity.userExists(db) else {
|
||||
return Fail(error: StorageError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let publicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let legacyDestination: Message.Destination = Message.Destination.contact(
|
||||
|
@ -202,8 +322,6 @@ extension MessageSender {
|
|||
namespace: .default
|
||||
)
|
||||
let legacyConfigurationMessage = try ConfigurationMessage.getCurrent(db)
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
let userConfigMessageChanges: [SharedConfigMessage] = SessionUtil.getChanges(
|
||||
ed25519SecretKey: ed25519SecretKey
|
||||
)
|
||||
|
@ -212,42 +330,7 @@ extension MessageSender {
|
|||
namespace: .userProfileConfig
|
||||
)
|
||||
|
||||
if forceSyncNow {
|
||||
try MessageSender
|
||||
.sendImmediate(db, message: legacyConfigurationMessage, to: legacyDestination, interactionId: nil)
|
||||
.done { seal.fulfill(()) }
|
||||
.catch { _ in seal.reject(StorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
when(
|
||||
resolved: try userConfigMessageChanges.map { message in
|
||||
try MessageSender
|
||||
.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: destination,
|
||||
interactionId: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.done { results in
|
||||
let hadError: Bool = results.contains { result in
|
||||
switch result {
|
||||
case .fulfilled: return false
|
||||
case .rejected: return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !hadError else {
|
||||
seal.reject(StorageError.generic)
|
||||
return
|
||||
}
|
||||
|
||||
seal.fulfill(())
|
||||
}
|
||||
.catch { _ in seal.reject(StorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
}
|
||||
else {
|
||||
guard forceSyncNow else {
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
|
@ -259,9 +342,66 @@ extension MessageSender {
|
|||
)
|
||||
)
|
||||
)
|
||||
seal.fulfill(())
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return promise
|
||||
let sendData: PreparedSendData = try MessageSender.preparedSendData(
|
||||
db,
|
||||
message: legacyConfigurationMessage,
|
||||
to: legacyDestination,
|
||||
interactionId: nil
|
||||
)
|
||||
|
||||
when(
|
||||
resolved: try userConfigMessageChanges.map { message in
|
||||
try MessageSender
|
||||
.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: destination,
|
||||
interactionId: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.done { results in
|
||||
let hadError: Bool = results.contains { result in
|
||||
switch result {
|
||||
case .fulfilled: return false
|
||||
case .rejected: return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !hadError else {
|
||||
seal.reject(StorageError.generic)
|
||||
return
|
||||
}
|
||||
|
||||
seal.fulfill(())
|
||||
}
|
||||
.catch { _ in seal.reject(StorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
|
||||
// TODO: Test this (does it break anything? want to stop the db write asap)
|
||||
return Future<Void, Error> { resolver in
|
||||
db.afterNextTransaction { _ in
|
||||
// TODO: Remove the 'Swift.'
|
||||
resolver(Swift.Result.success(()))
|
||||
}
|
||||
}
|
||||
.flatMap { _ in MessageSender.sendImmediate(data: sendData) }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// return MessageSender
|
||||
// .sendImmediate(
|
||||
// data: try MessageSender.preparedSendData(
|
||||
// db,
|
||||
// message: configurationMessage,
|
||||
// to: destination,
|
||||
// interactionId: nil
|
||||
// )
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,12 +1,12 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc(LKPushNotificationAPI)
|
||||
public final class PushNotificationAPI : NSObject {
|
||||
public enum PushNotificationAPI {
|
||||
struct RegistrationRequestBody: Codable {
|
||||
let token: String
|
||||
let pubKey: String?
|
||||
|
@ -28,13 +28,14 @@ public final class PushNotificationAPI : NSObject {
|
|||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
public static let server = "https://live.apns.getsession.org"
|
||||
public static let serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
|
||||
|
||||
private static let maxRetryCount: UInt = 4
|
||||
private static let maxRetryCount: Int = 4
|
||||
private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60
|
||||
|
||||
@objc public enum ClosedGroupOperation : Int {
|
||||
public enum ClosedGroupOperation: Int {
|
||||
case subscribe, unsubscribe
|
||||
|
||||
public var endpoint: String {
|
||||
|
@ -44,77 +45,100 @@ public final class PushNotificationAPI : NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() { }
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
public static func unregister(_ token: Data) -> Promise<Void> {
|
||||
public static func unregister(_ token: Data) -> AnyPublisher<Void, Error> {
|
||||
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Unsubscribe from all closed groups (including ones the user is no longer a member of,
|
||||
// just in case)
|
||||
Storage.shared
|
||||
.readPublisher { db -> (String, Set<String>) in
|
||||
(
|
||||
getUserHexEncodedPublicKey(db),
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchSet(db)
|
||||
)
|
||||
}
|
||||
.flatMap { userPublicKey, closedGroupPublicKeys in
|
||||
Publishers
|
||||
.MergeMany(
|
||||
closedGroupPublicKeys
|
||||
.map { closedGroupPublicKey -> AnyPublisher<Void, Error> in
|
||||
PushNotificationAPI
|
||||
.performOperation(
|
||||
.unsubscribe,
|
||||
for: closedGroupPublicKey,
|
||||
publicKey: userPublicKey
|
||||
)
|
||||
}
|
||||
)
|
||||
.collect()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.sinkUntilComplete()
|
||||
|
||||
// Unregister for normal push notifications
|
||||
let url = URL(string: "\(server)/unregister")!
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map2 { _, data in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't unregister from push notifications.")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").")
|
||||
return OnionRequestAPI
|
||||
.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map { _, data -> Void in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't unregister from push notifications.")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
.retry(maxRetryCount)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure: SNLog("Couldn't unregister from push notifications.")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
SNLog("Couldn't unregister from push notifications.")
|
||||
}
|
||||
|
||||
// Unsubscribe from all closed groups (including ones the user is no longer a member of, just in case)
|
||||
Storage.shared.read { db in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchAll(db)
|
||||
.forEach { closedGroupPublicKey in
|
||||
performOperation(.unsubscribe, for: closedGroupPublicKey, publicKey: userPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return promise
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@objc(unregisterToken:)
|
||||
public static func objc_unregister(token: Data) -> AnyPromise {
|
||||
return AnyPromise.from(unregister(token))
|
||||
}
|
||||
|
||||
public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise<Void> {
|
||||
|
||||
public static func register(
|
||||
with token: Data,
|
||||
publicKey: String,
|
||||
isForcedUpdate: Bool
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
let hexEncodedToken: String = token.toHexString()
|
||||
let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let userDefaults = UserDefaults.standard
|
||||
let oldToken = userDefaults[.deviceToken]
|
||||
let lastUploadTime = userDefaults[.lastDeviceTokenUpload]
|
||||
let now = Date().timeIntervalSince1970
|
||||
let oldToken: String? = UserDefaults.standard[.deviceToken]
|
||||
let lastUploadTime: Double = UserDefaults.standard[.lastDeviceTokenUpload]
|
||||
let now: TimeInterval = Date().timeIntervalSince1970
|
||||
|
||||
guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else {
|
||||
SNLog("Device token hasn't changed or expired; no need to re-upload.")
|
||||
return Promise<Void> { $0.fulfill(()) }
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let url = URL(string: "\(server)/register")!
|
||||
|
@ -123,67 +147,81 @@ public final class PushNotificationAPI : NSObject {
|
|||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
var promises: [Promise<Void>] = []
|
||||
|
||||
promises.append(
|
||||
attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map2 { _, data -> Void in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't register device token.")
|
||||
return Publishers
|
||||
.MergeMany(
|
||||
[
|
||||
OnionRequestAPI
|
||||
.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map { _, data -> Void in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't register device token.")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
|
||||
UserDefaults.standard[.deviceToken] = hexEncodedToken
|
||||
UserDefaults.standard[.lastDeviceTokenUpload] = now
|
||||
UserDefaults.standard[.isUsingFullAPNs] = true
|
||||
return ()
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
|
||||
userDefaults[.deviceToken] = hexEncodedToken
|
||||
userDefaults[.lastDeviceTokenUpload] = now
|
||||
userDefaults[.isUsingFullAPNs] = true
|
||||
}
|
||||
}
|
||||
)
|
||||
promises.first?.catch2 { error in
|
||||
SNLog("Couldn't register device token.")
|
||||
}
|
||||
|
||||
// Subscribe to all closed groups
|
||||
promises.append(
|
||||
contentsOf: Storage.shared
|
||||
.read { db -> [String] in
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.joining(
|
||||
required: ClosedGroup.members
|
||||
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
|
||||
.retry(maxRetryCount)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure: SNLog("Couldn't register device token.")
|
||||
}
|
||||
}
|
||||
)
|
||||
.asRequest(of: String.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.map { closedGroupPublicKey -> Promise<Void> in
|
||||
performOperation(.subscribe, for: closedGroupPublicKey, publicKey: publicKey)
|
||||
}
|
||||
)
|
||||
|
||||
return when(fulfilled: promises)
|
||||
.eraseToAnyPublisher()
|
||||
].appending(
|
||||
contentsOf: Storage.shared
|
||||
.read { db -> [String] in
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.joining(
|
||||
required: ClosedGroup.members
|
||||
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
|
||||
)
|
||||
.asRequest(of: String.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.map { closedGroupPublicKey -> AnyPublisher<Void, Error> in
|
||||
PushNotificationAPI
|
||||
.performOperation(
|
||||
.subscribe,
|
||||
for: closedGroupPublicKey,
|
||||
publicKey: publicKey
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.collect()
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@objc(registerWithToken:hexEncodedPublicKey:isForcedUpdate:)
|
||||
public static func objc_register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> AnyPromise {
|
||||
return AnyPromise.from(register(with: token, publicKey: publicKey, isForcedUpdate: isForcedUpdate))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> Promise<Void> {
|
||||
public static func performOperation(
|
||||
_ operation: ClosedGroupOperation,
|
||||
for closedGroupPublicKey: String,
|
||||
publicKey: String
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs]
|
||||
let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody(
|
||||
closedGroupPublicKey: closedGroupPublicKey,
|
||||
pubKey: publicKey
|
||||
)
|
||||
|
||||
guard isUsingFullAPNs else { return Promise<Void> { $0.fulfill(()) } }
|
||||
guard isUsingFullAPNs else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let url = URL(string: "\(server)/\(operation.endpoint)")!
|
||||
|
@ -192,26 +230,29 @@ public final class PushNotificationAPI : NSObject {
|
|||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map2 { _, data in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").")
|
||||
return OnionRequestAPI
|
||||
.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map { _, data in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
.retry(maxRetryCount)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
@objc(performOperation:forClosedGroupWithPublicKey:userPublicKey:)
|
||||
public static func objc_performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> AnyPromise {
|
||||
return AnyPromise.from(performOperation(operation, for: closedGroupPublicKey, publicKey: publicKey))
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Notify
|
||||
|
@ -219,13 +260,13 @@ public final class PushNotificationAPI : NSObject {
|
|||
public static func notify(
|
||||
recipient: String,
|
||||
with message: String,
|
||||
maxRetryCount: UInt? = nil,
|
||||
queue: DispatchQueue = DispatchQueue.global()
|
||||
) -> Promise<Void> {
|
||||
maxRetryCount: Int? = nil
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient)
|
||||
|
||||
guard let body: Data = try? JSONEncoder().encode(requestBody) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let url = URL(string: "\(server)/notify")!
|
||||
|
@ -234,19 +275,19 @@ public final class PushNotificationAPI : NSObject {
|
|||
request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ]
|
||||
request.httpBody = body
|
||||
|
||||
let retryCount: UInt = (maxRetryCount ?? PushNotificationAPI.maxRetryCount)
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: retryCount, recoveringOn: queue) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map2 { _, data in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't send push notification.")
|
||||
}
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
return OnionRequestAPI
|
||||
.sendOnionRequest(request, to: server, with: serverPublicKey)
|
||||
.map { _, data -> Void in
|
||||
guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else {
|
||||
return SNLog("Couldn't send push notification.")
|
||||
}
|
||||
}
|
||||
|
||||
return promise
|
||||
guard response.code != 0 else {
|
||||
return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").")
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
.retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,41 +1,29 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public final class ClosedGroupPoller {
|
||||
private var isPolling: Atomic<[String: Bool]> = Atomic([:])
|
||||
private var timers: [String: Timer] = [:]
|
||||
public final class ClosedGroupPoller: Poller {
|
||||
public static var namespaces: [SnodeAPI.Namespace] = [.legacyClosedGroup]
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
override var namespaces: [SnodeAPI.Namespace] { ClosedGroupPoller.namespaces }
|
||||
override var maxNodePollCount: UInt { 0 }
|
||||
|
||||
private static let minPollInterval: Double = 2
|
||||
private static let maxPollInterval: Double = 30
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
private enum Error: LocalizedError {
|
||||
case insufficientSnodes
|
||||
case pollingCanceled
|
||||
|
||||
internal var errorDescription: String? {
|
||||
switch self {
|
||||
case .insufficientSnodes: return "No snodes left to poll."
|
||||
case .pollingCanceled: return "Polling canceled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public static let shared = ClosedGroupPoller()
|
||||
public static let shared: ClosedGroupPoller = ClosedGroupPoller()
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
@objc public func start() {
|
||||
public func start() {
|
||||
// Fetch all closed groups (excluding any don't contain the current user as a
|
||||
// GroupMemeber as the user is no longer a member of those)
|
||||
Storage.shared
|
||||
|
@ -50,62 +38,24 @@ public final class ClosedGroupPoller {
|
|||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.forEach { [weak self] groupPublicKey in
|
||||
self?.startPolling(for: groupPublicKey)
|
||||
.forEach { [weak self] publicKey in
|
||||
self?.startIfNeeded(for: publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func startPolling(for groupPublicKey: String) {
|
||||
guard isPolling.wrappedValue[groupPublicKey] != true else { return }
|
||||
|
||||
// Might be a race condition that the setUpPolling finishes too soon,
|
||||
// and the timer is not created, if we mark the group as is polling
|
||||
// after setUpPolling. So the poller may not work, thus misses messages.
|
||||
isPolling.mutate { $0[groupPublicKey] = true }
|
||||
setUpPolling(for: groupPublicKey)
|
||||
}
|
||||
|
||||
public func stopAllPollers() {
|
||||
let pollers: [String] = Array(isPolling.wrappedValue.keys)
|
||||
|
||||
pollers.forEach { groupPublicKey in
|
||||
self.stopPolling(for: groupPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func stopPolling(for groupPublicKey: String) {
|
||||
isPolling.mutate { $0[groupPublicKey] = false }
|
||||
timers[groupPublicKey]?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - Private API
|
||||
// MARK: - Abstract Methods
|
||||
|
||||
private func setUpPolling(for groupPublicKey: String) {
|
||||
Threading.pollerQueue.async {
|
||||
ClosedGroupPoller.poll(groupPublicKey, poller: self)
|
||||
.done(on: Threading.pollerQueue) { [weak self] _ in
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
.catch(on: Threading.pollerQueue) { [weak self] error in
|
||||
// The error is logged in poll(_:)
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
}
|
||||
override func pollerName(for publicKey: String) -> String {
|
||||
return "closed group with public key: \(publicKey)"
|
||||
}
|
||||
|
||||
private func pollRecursively(_ groupPublicKey: String) {
|
||||
guard
|
||||
isPolling.wrappedValue[groupPublicKey] == true,
|
||||
let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) })
|
||||
else { return }
|
||||
|
||||
// Get the received date of the last message in the thread. If we don't have any messages yet, pick some
|
||||
// reasonable fake time interval to use instead
|
||||
|
||||
override func nextPollDelay(for publicKey: String) -> TimeInterval {
|
||||
// Get the received date of the last message in the thread. If we don't have
|
||||
// any messages yet, pick some reasonable fake time interval to use instead
|
||||
let lastMessageDate: Date = Storage.shared
|
||||
.read { db in
|
||||
try thread
|
||||
.interactions
|
||||
try Interaction
|
||||
.filter(Interaction.Columns.threadId == publicKey)
|
||||
.select(.receivedAtTimestampMs)
|
||||
.order(Interaction.Columns.timestampMs.desc)
|
||||
.asRequest(of: Int64.self)
|
||||
|
@ -121,193 +71,36 @@ public final class ClosedGroupPoller {
|
|||
let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate)
|
||||
let minPollInterval: Double = ClosedGroupPoller.minPollInterval
|
||||
let limit: Double = (12 * 60 * 60)
|
||||
let a = (ClosedGroupPoller.maxPollInterval - minPollInterval) / limit
|
||||
let nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval
|
||||
SNLog("Next poll interval for closed group with public key: \(groupPublicKey) is \(nextPollInterval) s.")
|
||||
let a: TimeInterval = ((ClosedGroupPoller.maxPollInterval - minPollInterval) / limit)
|
||||
let nextPollInterval: TimeInterval = a * min(timeSinceLastMessage, limit) + minPollInterval
|
||||
SNLog("Next poll interval for closed group with public key: \(publicKey) is \(nextPollInterval) s.")
|
||||
|
||||
timers[groupPublicKey] = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in
|
||||
timer.invalidate()
|
||||
|
||||
Threading.pollerQueue.async {
|
||||
ClosedGroupPoller.poll(groupPublicKey, poller: self)
|
||||
.done(on: Threading.pollerQueue) { _ in
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
.catch(on: Threading.pollerQueue) { error in
|
||||
// The error is logged in poll(_:)
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextPollInterval
|
||||
}
|
||||
|
||||
public static func poll(
|
||||
_ groupPublicKey: String,
|
||||
on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue,
|
||||
maxRetryCount: UInt = 0,
|
||||
calledFromBackgroundPoller: Bool = false,
|
||||
isBackgroundPollValid: @escaping (() -> Bool) = { true },
|
||||
poller: ClosedGroupPoller? = nil
|
||||
) -> Promise<Void> {
|
||||
let promise: Promise<Void> = SnodeAPI.getSwarm(for: groupPublicKey)
|
||||
.then(on: queue) { swarm -> Promise<Void> in
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) }
|
||||
|
||||
return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) {
|
||||
guard
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[groupPublicKey] == true
|
||||
else { return Promise(error: Error.pollingCanceled) }
|
||||
|
||||
let promises: [Promise<([SnodeReceivedMessage], String?)>] = {
|
||||
if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 {
|
||||
return [ SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) ]
|
||||
}
|
||||
|
||||
if SnodeAPI.hardfork >= 19 {
|
||||
return [
|
||||
SnodeAPI.getClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey),
|
||||
SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey, authenticated: false)
|
||||
]
|
||||
}
|
||||
|
||||
return [ SnodeAPI.getClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey) ]
|
||||
}()
|
||||
|
||||
return when(resolved: promises)
|
||||
.then(on: queue) { messageResults -> Promise<Void> in
|
||||
guard
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[groupPublicKey] == true
|
||||
else { return Promise.value(()) }
|
||||
|
||||
var promises: [Promise<Void>] = []
|
||||
var jobToRun: Job? = nil
|
||||
let allMessages: [SnodeReceivedMessage] = messageResults
|
||||
.reduce([]) { result, next in
|
||||
switch next {
|
||||
case .fulfilled(let data): return result.appending(contentsOf: data.0)
|
||||
default: return result
|
||||
}
|
||||
}
|
||||
let allHashes: [String] = messageResults
|
||||
.reduce([]) { result, next in
|
||||
switch next {
|
||||
case .fulfilled(let data): return result.appending(data.1)
|
||||
default: return result
|
||||
}
|
||||
}
|
||||
.compactMap { $0 }
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
// No need to do anything if there are no messages
|
||||
guard !allMessages.isEmpty else {
|
||||
if !calledFromBackgroundPoller {
|
||||
SNLog("Received no new messages in closed group with public key: \(groupPublicKey)")
|
||||
}
|
||||
return Promise.value(())
|
||||
}
|
||||
|
||||
// Otherwise process the messages and add them to the queue for handling
|
||||
Storage.shared.write { db in
|
||||
let processedMessages: [ProcessedMessage] = allMessages
|
||||
.compactMap { message -> ProcessedMessage? in
|
||||
do {
|
||||
return try Message.processRawReceivedMessage(db, rawMessage: message)
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||
// them as there will be a lot since we each service node duplicates messages)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||
MessageReceiverError.duplicateMessage,
|
||||
MessageReceiverError.duplicateControlMessage,
|
||||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
case MessageReceiverError.duplicateMessageNewSnode:
|
||||
hadValidHashUpdate = true
|
||||
break
|
||||
|
||||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
case DatabaseError.SQLITE_ABORT:
|
||||
guard !calledFromBackgroundPoller else { break }
|
||||
|
||||
SNLog("Failed to the database being suspended (running in background with no background task).")
|
||||
break
|
||||
|
||||
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
messageCount = processedMessages.count
|
||||
|
||||
jobToRun = Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: groupPublicKey,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: processedMessages.map { $0.messageInfo },
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller
|
||||
)
|
||||
)
|
||||
|
||||
// If we are force-polling then add to the JobRunner so they are persistent and will retry on
|
||||
// the next app run if they fail but don't let them auto-start
|
||||
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
|
||||
|
||||
if messageCount == 0 && !hadValidHashUpdate, !allHashes.isEmpty {
|
||||
SNLog("Received \(allMessages.count) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey), all duplicates - marking the hashes we polled with as invalid")
|
||||
|
||||
// Update the cached validity of the messages
|
||||
try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash(
|
||||
db,
|
||||
potentiallyInvalidHashes: allHashes,
|
||||
otherKnownValidHashes: allMessages.map { $0.info.hash }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if calledFromBackgroundPoller {
|
||||
// We want to try to handle the receive jobs immediately in the background
|
||||
promises = promises.appending(
|
||||
jobToRun.map { job -> Promise<Void> in
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
// Note: In the background we just want jobs to fail silently
|
||||
MessageReceiveJob.run(
|
||||
job,
|
||||
queue: queue,
|
||||
success: { _, _ in seal.fulfill(()) },
|
||||
failure: { _, _, _ in seal.fulfill(()) },
|
||||
deferred: { _ in seal.fulfill(()) }
|
||||
)
|
||||
|
||||
return promise
|
||||
}
|
||||
)
|
||||
}
|
||||
else if messageCount > 0 || hadValidHashUpdate {
|
||||
SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))")
|
||||
}
|
||||
|
||||
return when(fulfilled: promises)
|
||||
}
|
||||
override func getSnodeForPolling(
|
||||
for publicKey: String
|
||||
) -> AnyPublisher<Snode, Error> {
|
||||
return SnodeAPI.getSwarm(for: publicKey)
|
||||
.flatMap { swarm -> AnyPublisher<Snode, Error> in
|
||||
guard let snode: Snode = swarm.randomElement() else {
|
||||
return Fail(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(snode)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override func handlePollError(_ error: Error, for publicKey: String) {
|
||||
SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).")
|
||||
|
||||
if !calledFromBackgroundPoller {
|
||||
promise.catch2 { error in
|
||||
SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).")
|
||||
}
|
||||
// Try to restart the poller from scratch
|
||||
Threading.pollerQueue.async { [weak self] in
|
||||
self?.setUpPolling(for: publicKey)
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import Sodium
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -61,11 +62,12 @@ public final class CurrentUserPoller: Poller {
|
|||
}
|
||||
|
||||
override func getSnodeForPolling(
|
||||
for publicKey: String,
|
||||
on queue: DispatchQueue
|
||||
) -> Promise<Snode> {
|
||||
for publicKey: String
|
||||
) -> AnyPublisher<Snode, Error> {
|
||||
if let targetSnode: Snode = self.targetSnode.wrappedValue {
|
||||
return Promise.value(targetSnode)
|
||||
return Just(targetSnode)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Used the cached swarm for the given key and update the list of unusedSnodes
|
||||
|
@ -77,20 +79,26 @@ public final class CurrentUserPoller: Poller {
|
|||
self.targetSnode.mutate { $0 = nextSnode }
|
||||
self.usedSnodes.mutate { $0.insert(nextSnode) }
|
||||
|
||||
return Promise.value(nextSnode)
|
||||
return Just(nextSnode)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// If we haven't retrieved a target snode at this point then either the cache
|
||||
// is empty or we have used all of the snodes and need to start from scratch
|
||||
return SnodeAPI.getSwarm(for: publicKey)
|
||||
.then(on: queue) { [weak self] _ -> Promise<Snode> in
|
||||
guard let strongSelf = self else { return Promise(error: SnodeAPIError.generic) }
|
||||
.flatMap { [weak self] _ -> AnyPublisher<Snode, Error> in
|
||||
guard let strongSelf = self else {
|
||||
return Fail(error: SnodeAPIError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
self?.targetSnode.mutate { $0 = nil }
|
||||
self?.usedSnodes.mutate { $0.removeAll() }
|
||||
|
||||
return strongSelf.getSnodeForPolling(for: publicKey, on: queue)
|
||||
return strongSelf.getSnodeForPolling(for: publicKey)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override func handlePollError(_ error: Error, for publicKey: String) {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -12,8 +12,8 @@ extension OpenGroupAPI {
|
|||
|
||||
private let server: String
|
||||
private var timer: Timer? = nil
|
||||
private var hasStarted = false
|
||||
private var isPolling = false
|
||||
private var hasStarted: Bool = false
|
||||
private var isPolling: Bool = false
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
|
@ -28,6 +28,7 @@ extension OpenGroupAPI {
|
|||
}
|
||||
|
||||
public func startIfNeeded(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) {
|
||||
return// TODO: Remove this (reentrancy issues - looks like it could be resolved by splitting out the OpenGroupAPI request signing into it's own step)
|
||||
guard !hasStarted else { return }
|
||||
|
||||
hasStarted = true
|
||||
|
@ -55,7 +56,7 @@ extension OpenGroupAPI {
|
|||
.defaulting(to: 0)
|
||||
let nextPollInterval: TimeInterval = getInterval(for: minPollFailureCount, minInterval: Poller.minPollInterval, maxInterval: Poller.maxPollInterval)
|
||||
|
||||
poll(using: dependencies).retainUntilComplete()
|
||||
poll(using: dependencies).sinkUntilComplete()
|
||||
timer = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in
|
||||
timer.invalidate()
|
||||
|
||||
|
@ -65,131 +66,144 @@ extension OpenGroupAPI {
|
|||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise<Void> {
|
||||
return poll(calledFromBackgroundPoller: false, isPostCapabilitiesRetry: false, using: dependencies)
|
||||
public func poll(
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
return poll(
|
||||
calledFromBackgroundPoller: false,
|
||||
isPostCapabilitiesRetry: false,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func poll(
|
||||
calledFromBackgroundPoller: Bool,
|
||||
isBackgroundPollerValid: @escaping (() -> Bool) = { true },
|
||||
isPostCapabilitiesRetry: Bool,
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
) -> Promise<Void> {
|
||||
guard !self.isPolling else { return Promise.value(()) }
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
guard !self.isPolling else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
self.isPolling = true
|
||||
let server: String = self.server
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
promise.retainUntilComplete()
|
||||
|
||||
let pollingLogic: () -> Void = {
|
||||
dependencies.storage
|
||||
.read { db -> Promise<(Int64, PollResponse)> in
|
||||
let failureCount: Int64 = (try? OpenGroup
|
||||
.select(max(OpenGroup.Columns.pollFailureCount))
|
||||
.asRequest(of: Int64.self)
|
||||
.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(on: OpenGroupAPI.workQueue) { (failureCount, $0) }
|
||||
}
|
||||
.done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in
|
||||
return dependencies.storage
|
||||
.readPublisherFlatMap { db -> AnyPublisher<(Int64, PollResponse), Error> in
|
||||
let failureCount: Int64 = (try? OpenGroup
|
||||
.select(max(OpenGroup.Columns.pollFailureCount))
|
||||
.asRequest(of: Int64.self)
|
||||
.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()
|
||||
}
|
||||
.subscribe(
|
||||
// If this was run via the background poller then don't run on the pollerQueue
|
||||
// TODO: Need to test if this dispatches to the next run loop or blocks (want it to block)
|
||||
on: (calledFromBackgroundPoller ?
|
||||
DispatchQueue.main :
|
||||
Threading.pollerQueue
|
||||
)
|
||||
)
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] failureCount, response in
|
||||
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
|
||||
// If this was a background poll and the background poll is no longer valid
|
||||
// then just stop
|
||||
self?.isPolling = false
|
||||
seal.fulfill(())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
self?.isPolling = false
|
||||
self?.handlePollResponse(
|
||||
response,
|
||||
failureCount: failureCount,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
|
||||
dependencies.mutableCache.mutate { cache in
|
||||
cache.hasPerformedInitialPoll[server] = true
|
||||
cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970
|
||||
UserDefaults.standard[.lastOpen] = Date()
|
||||
}
|
||||
|
||||
|
||||
SNLog("Open group polling finished for \(server).")
|
||||
seal.fulfill(())
|
||||
}
|
||||
.catch(on: OpenGroupAPI.workQueue) { [weak self] error in
|
||||
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
|
||||
// If this was a background poll and the background poll is no longer valid
|
||||
// then just stop
|
||||
self?.isPolling = false
|
||||
seal.fulfill(())
|
||||
return
|
||||
}
|
||||
|
||||
// If we are retrying then the error is being handled so no need to continue (this
|
||||
// method will always resolve)
|
||||
self?.updateCapabilitiesAndRetryIfNeeded(
|
||||
)
|
||||
.map { _ in () }
|
||||
.catch { [weak self] error -> AnyPublisher<Void, Error> in
|
||||
guard
|
||||
let strongSelf = self,
|
||||
(!calledFromBackgroundPoller || isBackgroundPollerValid())
|
||||
else {
|
||||
// If this was a background poll and the background poll is no longer valid
|
||||
// then just stop
|
||||
self?.isPolling = false
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// If we are retrying then the error is being handled so no need to continue (this
|
||||
// method will always resolve)
|
||||
return strongSelf
|
||||
.updateCapabilitiesAndRetryIfNeeded(
|
||||
server: server,
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller,
|
||||
isBackgroundPollerValid: isBackgroundPollerValid,
|
||||
isPostCapabilitiesRetry: isPostCapabilitiesRetry,
|
||||
error: error
|
||||
)
|
||||
.done(on: OpenGroupAPI.workQueue) { [weak self] didHandleError in
|
||||
if !didHandleError && isBackgroundPollerValid() {
|
||||
// Increase the failure count
|
||||
let pollFailureCount: Int64 = Storage.shared
|
||||
.read { db in
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] didHandleError in
|
||||
if !didHandleError && isBackgroundPollerValid() {
|
||||
// Increase the failure count
|
||||
let pollFailureCount: Int64 = Storage.shared
|
||||
.read { db in
|
||||
try OpenGroup
|
||||
.filter(OpenGroup.Columns.server == server)
|
||||
.select(max(OpenGroup.Columns.pollFailureCount))
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: 0)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try OpenGroup
|
||||
.filter(OpenGroup.Columns.server == server)
|
||||
.select(max(OpenGroup.Columns.pollFailureCount))
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
.updateAll(
|
||||
db,
|
||||
OpenGroup.Columns.pollFailureCount.set(to: (pollFailureCount + 1))
|
||||
)
|
||||
}
|
||||
.defaulting(to: 0)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try OpenGroup
|
||||
.filter(OpenGroup.Columns.server == server)
|
||||
.updateAll(
|
||||
db,
|
||||
OpenGroup.Columns.pollFailureCount.set(to: (pollFailureCount + 1))
|
||||
)
|
||||
|
||||
SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).")
|
||||
}
|
||||
|
||||
SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).")
|
||||
|
||||
self?.isPolling = false
|
||||
}
|
||||
|
||||
self?.isPolling = false
|
||||
seal.fulfill(()) // The promise is just used to keep track of when we're done
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// If this was run via the background poller then don't run on the pollerQueue
|
||||
if calledFromBackgroundPoller {
|
||||
pollingLogic()
|
||||
}
|
||||
else {
|
||||
Threading.pollerQueue.async { pollingLogic() }
|
||||
}
|
||||
|
||||
return promise
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func updateCapabilitiesAndRetryIfNeeded(
|
||||
|
@ -199,7 +213,7 @@ extension OpenGroupAPI {
|
|||
isPostCapabilitiesRetry: Bool,
|
||||
error: Error,
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
) -> Promise<Bool> {
|
||||
) -> AnyPublisher<Bool, Error> {
|
||||
/// We want to custom handle a '400' error code due to not having blinded auth as it likely means that we join the
|
||||
/// OpenGroup before blinding was enabled and need to update it's capabilities
|
||||
///
|
||||
|
@ -212,12 +226,14 @@ extension OpenGroupAPI {
|
|||
statusCode == 400,
|
||||
let dataString: String = String(data: data, encoding: .utf8),
|
||||
dataString.contains("Invalid authentication: this server requires the use of blinded ids")
|
||||
else { return Promise.value(false) }
|
||||
else {
|
||||
return Just(false)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let (promise, seal) = Promise<Bool>.pending()
|
||||
|
||||
dependencies.storage
|
||||
.read { db in
|
||||
return dependencies.storage
|
||||
.readPublisherFlatMap { db in
|
||||
OpenGroupAPI.capabilities(
|
||||
db,
|
||||
server: server,
|
||||
|
@ -225,8 +241,13 @@ extension OpenGroupAPI {
|
|||
using: dependencies
|
||||
)
|
||||
}
|
||||
.then(on: OpenGroupAPI.workQueue) { [weak self] _, responseBody -> Promise<Void> in
|
||||
guard let strongSelf = self, isBackgroundPollerValid() else { return Promise.value(()) }
|
||||
.subscribe(on: OpenGroupAPI.workQueue)
|
||||
.flatMap { [weak self] _, responseBody -> AnyPublisher<Void, Error> in
|
||||
guard let strongSelf = self, isBackgroundPollerValid() else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Handle the updated capabilities and re-trigger the poll
|
||||
strongSelf.isPolling = false
|
||||
|
@ -247,15 +268,17 @@ extension OpenGroupAPI {
|
|||
isPostCapabilitiesRetry: true,
|
||||
using: dependencies
|
||||
)
|
||||
.ensure { seal.fulfill(true) }
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.catch(on: OpenGroupAPI.workQueue) { error in
|
||||
.map { _ in true }
|
||||
.catch { error -> AnyPublisher<Bool, Error> in
|
||||
SNLog("Open group updating capabilities failed due to error: \(error).")
|
||||
seal.fulfill(true)
|
||||
return Just(true)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
|
||||
return promise
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func handlePollResponse(
|
||||
|
|
|
@ -1,142 +1,238 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import Sodium
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public final class Poller {
|
||||
private var isPolling: Atomic<Bool> = Atomic(false)
|
||||
private var usedSnodes = Set<Snode>()
|
||||
private var pollCount = 0
|
||||
|
||||
public class Poller {
|
||||
private var timers: Atomic<[String: Timer]> = Atomic([:])
|
||||
internal var isPolling: Atomic<[String: Bool]> = Atomic([:])
|
||||
internal var pollCount: Atomic<[String: Int]> = Atomic([:])
|
||||
internal var failureCount: Atomic<[String: Int]> = Atomic([:])
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private static let pollInterval: TimeInterval = 1.5
|
||||
private static let retryInterval: TimeInterval = 0.25
|
||||
private static let maxRetryInterval: TimeInterval = 15
|
||||
/// The namespaces which this poller queries
|
||||
internal var namespaces: [SnodeAPI.Namespace] {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
/// After polling a given snode this many times we always switch to a new one.
|
||||
///
|
||||
/// The reason for doing this is that sometimes a snode will be giving us successful responses while
|
||||
/// it isn't actually getting messages from other snodes.
|
||||
private static let maxPollCount: UInt = 6
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
private enum Error: LocalizedError {
|
||||
case pollLimitReached
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .pollLimitReached: return "Poll limit reached for current snode."
|
||||
}
|
||||
}
|
||||
/// The number of times the poller can poll before swapping to a new snode
|
||||
internal var maxNodePollCount: UInt {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
public init() {}
|
||||
|
||||
public func startIfNeeded() {
|
||||
guard !isPolling.wrappedValue else { return }
|
||||
public func stopAllPollers() {
|
||||
let pollers: [String] = Array(isPolling.wrappedValue.keys)
|
||||
|
||||
SNLog("Started polling.")
|
||||
isPolling.mutate { $0 = true }
|
||||
setUpPolling()
|
||||
pollers.forEach { groupPublicKey in
|
||||
self.stopPolling(for: groupPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
SNLog("Stopped polling.")
|
||||
isPolling.mutate { $0 = false }
|
||||
usedSnodes.removeAll()
|
||||
|
||||
public func stopPolling(for publicKey: String) {
|
||||
isPolling.mutate { $0[publicKey] = false }
|
||||
timers.mutate { $0[publicKey]?.invalidate() }
|
||||
}
|
||||
|
||||
// MARK: - Abstract Methods
|
||||
|
||||
/// The name for this poller to appear in the logs
|
||||
internal func pollerName(for publicKey: String) -> String {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
internal func nextPollDelay(for publicKey: String) -> TimeInterval {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
internal func getSnodeForPolling(
|
||||
for publicKey: String
|
||||
) -> AnyPublisher<Snode, Error> {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
internal func handlePollError(_ error: Error, for publicKey: String) {
|
||||
preconditionFailure("abstract class - override in subclass")
|
||||
}
|
||||
|
||||
// MARK: - Private API
|
||||
|
||||
private func setUpPolling(delay: TimeInterval = Poller.retryInterval) {
|
||||
guard isPolling.wrappedValue else { return }
|
||||
internal func startIfNeeded(for publicKey: String) {
|
||||
guard isPolling.wrappedValue[publicKey] != true else { return }
|
||||
|
||||
Threading.pollerQueue.async {
|
||||
let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey())
|
||||
.then(on: Threading.pollerQueue) { [weak self] _ -> Promise<Void> in
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
self?.usedSnodes.removeAll()
|
||||
self?.pollNextSnode(seal: seal)
|
||||
|
||||
return promise
|
||||
}
|
||||
.done(on: Threading.pollerQueue) { [weak self] in
|
||||
guard self?.isPolling.wrappedValue == true else { return }
|
||||
|
||||
Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in
|
||||
self?.setUpPolling()
|
||||
// Might be a race condition that the setUpPolling finishes too soon,
|
||||
// and the timer is not created, if we mark the group as is polling
|
||||
// after setUpPolling. So the poller may not work, thus misses messages
|
||||
isPolling.mutate { $0[publicKey] = true }
|
||||
setUpPolling(for: publicKey)
|
||||
}
|
||||
|
||||
/// We want to initially trigger a poll against the target service node and then run the recursive polling,
|
||||
/// if an error is thrown during the poll then this should automatically restart the polling
|
||||
internal func setUpPolling(for publicKey: String) {
|
||||
guard isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
||||
|
||||
getSnodeForPolling(for: publicKey)
|
||||
.subscribe(on: Threading.pollerQueue)
|
||||
.flatMap { snode -> AnyPublisher<Void, Error> in
|
||||
Poller.poll(
|
||||
namespaces: namespaces,
|
||||
from: snode,
|
||||
for: publicKey,
|
||||
on: Threading.pollerQueue,
|
||||
poller: self
|
||||
)
|
||||
}
|
||||
.receive(on: Threading.pollerQueue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: self?.pollRecursively(for: publicKey)
|
||||
case .failure(let error):
|
||||
guard self?.isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
self?.handlePollError(error, for: publicKey)
|
||||
}
|
||||
}
|
||||
.catch(on: Threading.pollerQueue) { [weak self] _ in
|
||||
guard self?.isPolling.wrappedValue == true else { return }
|
||||
|
||||
let nextDelay: TimeInterval = min(Poller.maxRetryInterval, (delay * 1.2))
|
||||
Timer.scheduledTimerOnMainThread(withTimeInterval: nextDelay, repeats: false) { _ in
|
||||
self?.setUpPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func pollNextSnode(seal: Resolver<Void>) {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let swarm = SnodeAPI.swarmCache.wrappedValue[userPublicKey] ?? []
|
||||
let unusedSnodes = swarm.subtracting(usedSnodes)
|
||||
private func pollRecursively(for publicKey: String) {
|
||||
guard isPolling.wrappedValue[publicKey] == true else { return }
|
||||
|
||||
guard !unusedSnodes.isEmpty else {
|
||||
seal.fulfill(())
|
||||
return
|
||||
}
|
||||
let namespaces: [SnodeAPI.Namespace] = self.namespaces
|
||||
let nextPollInterval: TimeInterval = nextPollDelay(for: publicKey)
|
||||
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
let nextSnode = unusedSnodes.randomElement()!
|
||||
usedSnodes.insert(nextSnode)
|
||||
|
||||
poll(nextSnode, seal: seal)
|
||||
.done2 {
|
||||
seal.fulfill(())
|
||||
timers.mutate {
|
||||
$0[publicKey] = Timer.scheduledTimerOnMainThread(
|
||||
withTimeInterval: nextPollInterval,
|
||||
repeats: false
|
||||
) { [weak self] timer in
|
||||
timer.invalidate()
|
||||
|
||||
self?.getSnodeForPolling(for: publicKey)
|
||||
.subscribe(on: Threading.pollerQueue)
|
||||
.flatMap { snode -> AnyPublisher<Void, Error> in
|
||||
Poller.poll(
|
||||
namespaces: namespaces,
|
||||
from: snode,
|
||||
for: publicKey,
|
||||
on: Threading.pollerQueue,
|
||||
poller: self
|
||||
)
|
||||
}
|
||||
.receive(on: Threading.pollerQueue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure(let error): self?.handlePollError(error, for: publicKey)
|
||||
case .finished:
|
||||
let maxNodePollCount: UInt = (self?.maxNodePollCount ?? 0)
|
||||
|
||||
// If we have polled this service node more than the
|
||||
// maximum allowed then throw an error so the parent
|
||||
// loop can restart the polling
|
||||
if maxNodePollCount > 0 {
|
||||
let pollCount: Int = (self?.pollCount.wrappedValue[publicKey] ?? 0)
|
||||
self?.pollCount.mutate { $0[publicKey] = (pollCount + 1) }
|
||||
|
||||
guard pollCount < maxNodePollCount else {
|
||||
let newSnodeNextPollInterval: TimeInterval = (self?.nextPollDelay(for: publicKey) ?? nextPollInterval)
|
||||
|
||||
self?.timers.mutate {
|
||||
$0[publicKey] = Timer.scheduledTimerOnMainThread(
|
||||
withTimeInterval: newSnodeNextPollInterval,
|
||||
repeats: false
|
||||
) { [weak self] timer in
|
||||
timer.invalidate()
|
||||
|
||||
self?.pollCount.mutate { $0[publicKey] = 0 }
|
||||
self?.setUpPolling(for: publicKey)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise just loop
|
||||
self?.pollRecursively(for: publicKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.catch2 { [weak self] error in
|
||||
if let error = error as? Error, error == .pollLimitReached {
|
||||
self?.pollCount = 0
|
||||
}
|
||||
else if UserDefaults.sharedLokiProject?[.isMainAppActive] != true {
|
||||
// Do nothing when an error gets throws right after returning from the background (happens frequently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func poll(
|
||||
namespaces: [SnodeAPI.Namespace],
|
||||
from snode: Snode,
|
||||
for publicKey: String,
|
||||
on queue: DispatchQueue,
|
||||
calledFromBackgroundPoller: Bool = false,
|
||||
isBackgroundPollValid: @escaping (() -> Bool) = { true },
|
||||
poller: Poller? = nil
|
||||
) -> AnyPublisher<Void, Error> {
|
||||
// If the polling has been cancelled then don't continue
|
||||
guard
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[publicKey] == true
|
||||
else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let pollerName: String = (
|
||||
poller?.pollerName(for: publicKey) ??
|
||||
"poller with public key \(publicKey)"
|
||||
)
|
||||
|
||||
// Fetch the messages
|
||||
return SnodeAPI.getMessages(in: namespaces, from: snode, associatedWith: publicKey)
|
||||
.flatMap { namespacedResults -> AnyPublisher<Void, Error> in
|
||||
guard
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[publicKey] == true
|
||||
else {
|
||||
SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.")
|
||||
SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey)
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
Threading.pollerQueue.async {
|
||||
self?.pollNextSnode(seal: seal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func poll(_ snode: Snode, seal longTermSeal: Resolver<Void>) -> Promise<Void> {
|
||||
guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } }
|
||||
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey)
|
||||
.then(on: Threading.pollerQueue) { [weak self] messages, lastHash -> Promise<Void> in
|
||||
guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } }
|
||||
let allMessagesCount: Int = namespacedResults
|
||||
.map { $0.value.data?.messages.count ?? 0 }
|
||||
.reduce(0, +)
|
||||
|
||||
if !messages.isEmpty {
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
|
||||
Storage.shared.write { db in
|
||||
messages
|
||||
// No need to do anything if there are no messages
|
||||
guard allMessagesCount > 0 else {
|
||||
if !calledFromBackgroundPoller {
|
||||
SNLog("Received no new messages in \(pollerName)")
|
||||
}
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Otherwise process the messages and add them to the queue for handling
|
||||
let lastHashes: [String] = namespacedResults
|
||||
.compactMap { $0.value.data?.lastHash }
|
||||
var messageCount: Int = 0
|
||||
var hadValidHashUpdate: Bool = false
|
||||
var jobsToRun: [Job] = []
|
||||
|
||||
Storage.shared.write { db in
|
||||
namespacedResults.forEach { namespace, result in
|
||||
result.data?.messages
|
||||
.compactMap { message -> ProcessedMessage? in
|
||||
do {
|
||||
return try Message.processRawReceivedMessage(db, rawMessage: message)
|
||||
|
@ -198,23 +294,35 @@ public final class Poller {
|
|||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
SNLog("Received no new messages")
|
||||
|
||||
// If we aren't runing in a background poller then just finish immediately
|
||||
guard calledFromBackgroundPoller else {
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
self?.pollCount += 1
|
||||
|
||||
guard (self?.pollCount ?? 0) < Poller.maxPollCount else {
|
||||
throw Error.pollLimitReached
|
||||
}
|
||||
|
||||
return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) {
|
||||
guard let strongSelf = self, strongSelf.isPolling.wrappedValue else {
|
||||
return Promise { $0.fulfill(()) }
|
||||
}
|
||||
|
||||
return strongSelf.poll(snode, seal: longTermSeal)
|
||||
}
|
||||
// We want to try to handle the receive jobs immediately in the background
|
||||
return Publishers
|
||||
.MergeMany(
|
||||
jobsToRun.map { job -> AnyPublisher<Void, Error> in
|
||||
Future<Void, Error> { resolver in
|
||||
// Note: In the background we just want jobs to fail silently
|
||||
MessageReceiveJob.run(
|
||||
job,
|
||||
queue: queue,
|
||||
success: { _, _ in resolver(Result.success(())) },
|
||||
failure: { _, _, _ in resolver(Result.success(())) },
|
||||
deferred: { _ in resolver(Result.success(())) }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
)
|
||||
.collect()
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import UIKit
|
||||
import CryptoKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -185,22 +185,27 @@ public struct ProfileManager {
|
|||
return
|
||||
}
|
||||
|
||||
let queue: DispatchQueue = DispatchQueue.global(qos: .default)
|
||||
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
|
||||
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
|
||||
var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName)
|
||||
|
||||
queue.async {
|
||||
OWSLogger.verbose("downloading profile avatar: \(profile.id)")
|
||||
currentAvatarDownloads.mutate { $0.insert(profile.id) }
|
||||
|
||||
let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer))
|
||||
|
||||
FileServerAPI
|
||||
.download(fileId, useOldServer: useOldServer)
|
||||
.done(on: queue) { data in
|
||||
OWSLogger.verbose("downloading profile avatar: \(profile.id)")
|
||||
currentAvatarDownloads.mutate { $0.insert(profile.id) }
|
||||
|
||||
let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer))
|
||||
|
||||
FileServerAPI
|
||||
.download(fileId, useOldServer: useOldServer)
|
||||
.receive(on: DispatchQueue.global(qos: .default))
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
currentAvatarDownloads.mutate { $0.remove(profile.id) }
|
||||
|
||||
// Redundant but without reading 'backgroundTask' it will warn that the variable
|
||||
// isn't used
|
||||
if backgroundTask != nil { backgroundTask = nil }
|
||||
},
|
||||
receiveValue: { data in
|
||||
guard let latestProfile: Profile = Storage.shared.read({ db in try Profile.fetchOne(db, id: profile.id) }) else {
|
||||
return
|
||||
}
|
||||
|
@ -242,20 +247,8 @@ public struct ProfileManager {
|
|||
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
|
||||
profileAvatarCache.mutate { $0[fileName] = decryptedData }
|
||||
}
|
||||
|
||||
// Redundant but without reading 'backgroundTask' it will warn that the variable
|
||||
// isn't used
|
||||
if backgroundTask != nil { backgroundTask = nil }
|
||||
}
|
||||
.catch(on: queue) { _ in
|
||||
currentAvatarDownloads.mutate { $0.remove(profile.id) }
|
||||
|
||||
// Redundant but without reading 'backgroundTask' it will warn that the variable
|
||||
// isn't used
|
||||
if backgroundTask != nil { backgroundTask = nil }
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Current User Profile
|
||||
|
@ -463,38 +456,31 @@ public struct ProfileManager {
|
|||
// Upload the avatar to the FileServer
|
||||
FileServerAPI
|
||||
.upload(encryptedAvatarData)
|
||||
.done(on: queue) { fileUploadResponse in
|
||||
let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileUploadResponse.id)"
|
||||
UserDefaults.standard[.lastProfilePictureUpload] = Date()
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
let profile: Profile = try Profile
|
||||
.fetchOrCreateCurrentUser(db)
|
||||
.with(
|
||||
name: profileName,
|
||||
profilePictureUrl: .update(downloadUrl),
|
||||
profilePictureFileName: .update(fileName),
|
||||
profileEncryptionKey: .update(newProfileKey)
|
||||
)
|
||||
.saved(db)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
SNLog("Updating service with profile failed.")
|
||||
|
||||
let isMaxFileSizeExceeded: Bool = ((error as? HTTPError) == .maxFileSizeExceeded)
|
||||
failure?(isMaxFileSizeExceeded ?
|
||||
.avatarUploadMaxFileSizeExceeded :
|
||||
.avatarUploadFailed
|
||||
)
|
||||
}
|
||||
},
|
||||
receiveValue: { fileUploadResponse in
|
||||
let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileUploadResponse.id)"
|
||||
|
||||
// Update the cached avatar image value
|
||||
profileAvatarCache.mutate { $0[fileName] = data }
|
||||
|
||||
SNLog("Successfully updated service with profile.")
|
||||
try success?(db, profile)
|
||||
SNLog("Successfully uploaded avatar image.")
|
||||
success((downloadUrl, fileName), newProfileKey)
|
||||
}
|
||||
}
|
||||
.recover(on: queue) { error in
|
||||
SNLog("Updating service with profile failed.")
|
||||
|
||||
let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded)
|
||||
failure?(isMaxFileSizeExceeded ?
|
||||
.avatarUploadMaxFileSizeExceeded :
|
||||
.avatarUploadFailed
|
||||
)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import CallKit
|
||||
import UserNotifications
|
||||
import BackgroundTasks
|
||||
import PromiseKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
@ -46,11 +46,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
|
||||
// Handle the push notification
|
||||
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
||||
let openGroupPollingPromises = self.pollForOpenGroups()
|
||||
let openGroupPollingPublishers: [AnyPublisher<Void, Error>] = self.pollForOpenGroups()
|
||||
defer {
|
||||
when(resolved: openGroupPollingPromises).done { _ in
|
||||
self.completeSilenty()
|
||||
}
|
||||
// TODO: Test this
|
||||
Publishers
|
||||
.MergeMany(openGroupPollingPublishers)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { _ in
|
||||
self.completeSilenty()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
|
@ -211,7 +216,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
// If we need a config sync then trigger it now
|
||||
if needsConfigSync {
|
||||
Storage.shared.write { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -322,8 +327,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
|
||||
// MARK: - Poll for open groups
|
||||
|
||||
private func pollForOpenGroups() -> [Promise<Void>] {
|
||||
let promises: [Promise<Void>] = Storage.shared
|
||||
private func pollForOpenGroups() -> [AnyPublisher<Void, Error>] {
|
||||
return Storage.shared
|
||||
.read { db in
|
||||
// The default room promise creates an OpenGroup with an empty `roomToken` value,
|
||||
// we don't want to start a poller for this as the user hasn't actually joined a room
|
||||
|
@ -336,16 +341,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
|
|||
.fetchSet(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.map { server in
|
||||
.map { server -> AnyPublisher<Void, Error> in
|
||||
OpenGroupAPI.Poller(for: server)
|
||||
.poll(calledFromBackgroundPoller: true, isPostCapabilitiesRetry: false)
|
||||
.timeout(
|
||||
seconds: 20,
|
||||
timeoutError: NotificationServiceError.timeout
|
||||
.seconds(20),
|
||||
scheduler: DispatchQueue.global(qos: .default),
|
||||
customError: { NotificationServiceError.timeout }
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return promises
|
||||
}
|
||||
|
||||
private enum NotificationServiceError: Error {
|
||||
|
|
|
@ -93,7 +93,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate {
|
|||
// If we need a config sync then trigger it now
|
||||
if needsConfigSync {
|
||||
Storage.shared.write { db in
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -28,15 +29,22 @@ public enum GetSnodePoolJob: JobExecutor {
|
|||
// to block if we have no Snode pool and prevent other jobs from failing but avoids having to
|
||||
// wait if we already have a potentially valid snode pool
|
||||
guard !SnodeAPI.hasCachedSnodesInclusingExpired() else {
|
||||
SnodeAPI.getSnodePool().retainUntilComplete()
|
||||
SnodeAPI.getSnodePool().sinkUntilComplete()
|
||||
success(job, false)
|
||||
return
|
||||
}
|
||||
|
||||
SnodeAPI.getSnodePool()
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
.catch(on: queue) { error in failure(job, error, false) }
|
||||
.retainUntilComplete()
|
||||
.subscribe(on: queue)
|
||||
.receive(on: queue)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: success(job, false)
|
||||
case .failure(let error): failure(job, error, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public static func run() {
|
||||
|
|
|
@ -11,6 +11,7 @@ public enum SnodeAPIError: LocalizedError {
|
|||
case signingFailed
|
||||
case signatureVerificationFailed
|
||||
case invalidIP
|
||||
case emptySnodePool
|
||||
|
||||
// ONS
|
||||
case decryptionFailed
|
||||
|
@ -27,6 +28,7 @@ public enum SnodeAPIError: LocalizedError {
|
|||
case .signingFailed: return "Couldn't sign message."
|
||||
case .signatureVerificationFailed: return "Failed to verify the signature."
|
||||
case .invalidIP: return "Invalid IP."
|
||||
case .emptySnodePool: return "Service Node pool is empty."
|
||||
|
||||
// ONS
|
||||
case .decryptionFailed: return "Couldn't decrypt ONS name."
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,12 +1,14 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CryptoSwift
|
||||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
internal extension OnionRequestAPI {
|
||||
|
||||
static func encode(ciphertext: Data, json: JSON) throws -> Data {
|
||||
static func encodeLegacy(ciphertext: Data, json: JSON) throws -> Data {
|
||||
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
|
||||
guard JSONSerialization.isValidJSONObject(json) else { throw HTTP.Error.invalidJSON }
|
||||
let jsonAsData = try JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ])
|
||||
|
@ -14,6 +16,24 @@ internal extension OnionRequestAPI {
|
|||
let ciphertextSizeAsData = withUnsafePointer(to: ciphertextSize) { Data(bytes: $0, count: MemoryLayout<Int32>.size) }
|
||||
return ciphertextSizeAsData + ciphertext + jsonAsData
|
||||
}
|
||||
|
||||
static func encode(ciphertext: Data, json: JSON) -> AnyPublisher<Data, Error> {
|
||||
// The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 |
|
||||
guard
|
||||
JSONSerialization.isValidJSONObject(json),
|
||||
let jsonAsData = try? JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ])
|
||||
else {
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let ciphertextSize = Int32(ciphertext.count).littleEndian
|
||||
let ciphertextSizeAsData = withUnsafePointer(to: ciphertextSize) { Data(bytes: $0, count: MemoryLayout<Int32>.size) }
|
||||
|
||||
return Just(ciphertextSizeAsData + ciphertext + jsonAsData)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||
static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> Promise<AESGCM.EncryptionResult> {
|
||||
|
@ -23,7 +43,7 @@ internal extension OnionRequestAPI {
|
|||
switch destination {
|
||||
case .snode(let snode):
|
||||
// Need to wrap the payload for snode requests
|
||||
let data: Data = try encode(ciphertext: payload, json: [ "headers" : "" ])
|
||||
let data: Data = try encodeLegacy(ciphertext: payload, json: [ "headers" : "" ])
|
||||
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey)
|
||||
seal.fulfill(result)
|
||||
|
||||
|
@ -39,6 +59,34 @@ internal extension OnionRequestAPI {
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
|
||||
static func encrypt(
|
||||
_ payload: Data,
|
||||
for destination: OnionRequestAPIDestination
|
||||
) -> AnyPublisher<AESGCM.EncryptionResult, Error> {
|
||||
return Future { resolver in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
switch destination {
|
||||
case .snode(let snode):
|
||||
// Need to wrap the payload for snode requests
|
||||
let data: Data = try encodeLegacy(ciphertext: payload, json: [ "headers" : "" ])
|
||||
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey)
|
||||
resolver(Swift.Result.success(result))
|
||||
|
||||
case .server(_, _, let serverX25519PublicKey, _, _):
|
||||
let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey)
|
||||
resolver(Swift.Result.success(result))
|
||||
}
|
||||
}
|
||||
catch (let error) {
|
||||
resolver(Swift.Result.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
static func encryptHop(from lhs: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise<AESGCM.EncryptionResult> {
|
||||
|
@ -72,7 +120,7 @@ internal extension OnionRequestAPI {
|
|||
}
|
||||
|
||||
do {
|
||||
let plaintext = try encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters)
|
||||
let plaintext = try encodeLegacy(ciphertext: previousEncryptionResult.ciphertext, json: parameters)
|
||||
let result = try AESGCM.encrypt(plaintext, for: x25519PublicKey)
|
||||
seal.fulfill(result)
|
||||
}
|
||||
|
@ -83,4 +131,51 @@ internal extension OnionRequestAPI {
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
static func encryptHop(
|
||||
from lhs: OnionRequestAPIDestination,
|
||||
to rhs: OnionRequestAPIDestination,
|
||||
using previousEncryptionResult: AESGCM.EncryptionResult
|
||||
) -> AnyPublisher<AESGCM.EncryptionResult, Error> {
|
||||
return Future { resolver in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var parameters: JSON
|
||||
|
||||
switch rhs {
|
||||
case .snode(let snode):
|
||||
let snodeED25519PublicKey = snode.ed25519PublicKey
|
||||
parameters = [ "destination" : snodeED25519PublicKey ]
|
||||
|
||||
case .server(let host, let target, _, let scheme, let port):
|
||||
let scheme = scheme ?? "https"
|
||||
let port = port ?? (scheme == "https" ? 443 : 80)
|
||||
parameters = [ "host" : host, "target" : target, "method" : "POST", "protocol" : scheme, "port" : port ]
|
||||
}
|
||||
|
||||
parameters["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
|
||||
let x25519PublicKey: String
|
||||
|
||||
switch lhs {
|
||||
case .snode(let snode):
|
||||
let snodeX25519PublicKey = snode.x25519PublicKey
|
||||
x25519PublicKey = snodeX25519PublicKey
|
||||
|
||||
case .server(_, _, let serverX25519PublicKey, _, _):
|
||||
x25519PublicKey = serverX25519PublicKey
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = try encodeLegacy(ciphertext: previousEncryptionResult.ciphertext, json: parameters)
|
||||
let result = try AESGCM.encrypt(plaintext, for: x25519PublicKey)
|
||||
resolver(Swift.Result.success(result))
|
||||
}
|
||||
catch (let error) {
|
||||
resolver(Swift.Result.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CryptoSwift
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public protocol OnionRequestAPIType {
|
||||
static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise<Data>
|
||||
static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)>
|
||||
}
|
||||
|
||||
public extension OnionRequestAPIType {
|
||||
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
sendOnionRequest(request, to: server, using: .v4, with: x25519PublicKey)
|
||||
}
|
||||
// static func sendOnionRequest(_ payload: Data, to snode: Snode) -> Promise<(ResponseInfoType, Data?)>
|
||||
// static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> Promise<(ResponseInfoType, Data?)>
|
||||
|
||||
static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error>
|
||||
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error>
|
||||
}
|
||||
|
||||
/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||
public enum OnionRequestAPI: OnionRequestAPIType {
|
||||
private static var buildPathsPromise: Promise<[[Snode]]>? = nil
|
||||
private static var buildPathsPublisher: Atomic<AnyPublisher<[[Snode]], Error>?> = Atomic(nil)
|
||||
|
||||
/// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions.
|
||||
private static var pathFailureCount: [[Snode]: UInt] = [:]
|
||||
|
@ -66,6 +65,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data)
|
||||
|
||||
// MARK: - Private API
|
||||
|
||||
/// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
|
||||
private static func testSnode(_ snode: Snode) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
@ -74,8 +74,9 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
let url = "\(snode.address):\(snode.port)/get_stats/v1"
|
||||
let timeout: TimeInterval = 3 // Use a shorter timeout for testing
|
||||
|
||||
HTTP.execute(.get, url, timeout: timeout)
|
||||
HTTP.executeLegacy(.get, url, timeout: timeout)
|
||||
.done2 { responseData in
|
||||
// TODO: Remove JSON usage
|
||||
guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else {
|
||||
throw HTTP.Error.invalidJSON
|
||||
}
|
||||
|
@ -98,9 +99,39 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise.
|
||||
private static func testSnode(_ snode: Snode) -> AnyPublisher<Void, Error> {
|
||||
let url = "\(snode.address):\(snode.port)/get_stats/v1"
|
||||
let timeout: TimeInterval = 3 // Use a shorter timeout for testing
|
||||
|
||||
return HTTP.execute(.get, url, timeout: timeout)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.flatMap { responseData -> AnyPublisher<Void, Error> in
|
||||
// TODO: Remove JSON usage
|
||||
guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else {
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let version = responseJson["version"] as? String else {
|
||||
return Fail(error: OnionRequestAPIError.missingSnodeVersion)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard version >= "2.0.7" else {
|
||||
SNLog("Unsupported snode version: \(version).")
|
||||
return Fail(error: OnionRequestAPIError.unsupportedSnodeVersion(version))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
/// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with
|
||||
/// `Error.insufficientSnodes` if not enough (reliable) snodes are available.
|
||||
private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise<Set<Snode>> {
|
||||
if guardSnodes.count >= targetGuardSnodeCount {
|
||||
return Promise<Set<Snode>> { $0.fulfill(guardSnodes) }
|
||||
|
@ -143,6 +174,62 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with
|
||||
/// `Error.insufficientSnodes` if not enough (reliable) snodes are available.
|
||||
private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> AnyPublisher<Set<Snode>, Error> {
|
||||
guard guardSnodes.count < targetGuardSnodeCount else {
|
||||
return Just(guardSnodes)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
SNLog("Populating guard snode cache.")
|
||||
// Sync on LokiAPI.workQueue
|
||||
var unusedSnodes = SnodeAPI.snodePool.wrappedValue.subtracting(reusableGuardSnodes)
|
||||
let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count)
|
||||
|
||||
guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else {
|
||||
return Fail(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getGuardSnode() -> AnyPublisher<Snode, Error> {
|
||||
// randomElement() uses the system's default random generator, which
|
||||
// is cryptographically secure
|
||||
guard let candidate = unusedSnodes.randomElement() else {
|
||||
return Fail(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
unusedSnodes.remove(candidate) // All used snodes should be unique
|
||||
SNLog("Testing guard snode: \(candidate).")
|
||||
|
||||
// Loop until a reliable guard snode is found
|
||||
return testSnode(candidate)
|
||||
.map { _ in candidate }
|
||||
.catch { _ in
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.delay(for: .milliseconds(100), scheduler: Threading.workQueue)
|
||||
.flatMap { _ in getGuardSnode() }
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let publishers = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount))
|
||||
.map { _ in getGuardSnode() }
|
||||
|
||||
return Publishers.MergeMany(publishers)
|
||||
.collect()
|
||||
.map { output in Set(output) }
|
||||
.handleEvents(
|
||||
receiveOutput: { output in
|
||||
OnionRequestAPI.guardSnodes = output
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
|
@ -196,6 +283,73 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
buildPathsPromise = promise
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
@discardableResult
|
||||
private static func buildPaths(reusing reusablePaths: [[Snode]]) -> AnyPublisher<[[Snode]], Error> {
|
||||
if let existingBuildPathsPublisher = buildPathsPublisher.wrappedValue {
|
||||
return existingBuildPathsPublisher
|
||||
}
|
||||
|
||||
SNLog("Building onion request paths.")
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .buildingPaths, object: nil)
|
||||
}
|
||||
let reusableGuardSnodes = reusablePaths.map { $0[0] }
|
||||
let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes)
|
||||
.flatMap { guardSnodes -> AnyPublisher<[[Snode]], Error> in
|
||||
var unusedSnodes = SnodeAPI.snodePool.wrappedValue
|
||||
.subtracting(guardSnodes)
|
||||
.subtracting(reusablePaths.flatMap { $0 })
|
||||
let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count)
|
||||
let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
|
||||
|
||||
guard unusedSnodes.count >= pathSnodeCount else {
|
||||
return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
return Just(
|
||||
guardSnodes
|
||||
.subtracting(reusableGuardSnodes)
|
||||
.map { guardSnode in
|
||||
let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above
|
||||
unusedSnodes.remove(pathSnode) // All used snodes should be unique
|
||||
return pathSnode
|
||||
}
|
||||
|
||||
SNLog("Built new onion request path: \(result.prettifiedDescription).")
|
||||
return result
|
||||
}
|
||||
)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveOutput: { output in
|
||||
OnionRequestAPI.paths = (output + reusablePaths)
|
||||
|
||||
Storage.shared.write { db in
|
||||
SNLog("Persisting onion request paths to database.")
|
||||
try? output.save(db)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
|
||||
}
|
||||
},
|
||||
receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } }
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
buildPathsPublisher.mutate { $0 = publisher }
|
||||
|
||||
return publisher
|
||||
}
|
||||
|
||||
/// Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
private static func getPath(excluding snode: Snode?) -> Promise<[Snode]> {
|
||||
|
@ -223,7 +377,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
else if !paths.isEmpty {
|
||||
if let snode = snode {
|
||||
if let path = paths.first(where: { !$0.contains(snode) }) {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
let tmp: Promise<[[Snode]]> = buildPaths(reusing: paths) // Re-build paths in the background
|
||||
return Promise { $0.fulfill(path) }
|
||||
}
|
||||
else {
|
||||
|
@ -237,7 +391,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
}
|
||||
else {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
let tmp: Promise<[[Snode]]> = buildPaths(reusing: paths) // Re-build paths in the background
|
||||
|
||||
guard let path: [Snode] = paths.randomElement() else {
|
||||
return Promise(error: OnionRequestAPIError.insufficientSnodes)
|
||||
|
@ -264,6 +418,100 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
private static func getPath(excluding snode: Snode?) -> AnyPublisher<[Snode], Error> {
|
||||
guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") }
|
||||
|
||||
let paths: [[Snode]] = OnionRequestAPI.paths
|
||||
var cancellable: [AnyCancellable] = []
|
||||
|
||||
if !paths.isEmpty {
|
||||
guardSnodes.formUnion([ paths[0][0] ])
|
||||
|
||||
if paths.count >= 2 {
|
||||
guardSnodes.formUnion([ paths[1][0] ])
|
||||
}
|
||||
}
|
||||
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
if
|
||||
paths.count >= targetPathCount,
|
||||
let targetPath: [Snode] = paths
|
||||
.filter({ snode == nil || !$0.contains(snode!) })
|
||||
.randomElement()
|
||||
{
|
||||
return Just(targetPath)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
else if !paths.isEmpty {
|
||||
if let snode = snode {
|
||||
if let path = paths.first(where: { !$0.contains(snode) }) {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
|
||||
.store(in: &cancellable)
|
||||
|
||||
return Just(path)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
else {
|
||||
return buildPaths(reusing: paths)
|
||||
.flatMap { paths in
|
||||
guard let path: [Snode] = paths.filter({ !$0.contains(snode) }).randomElement() else {
|
||||
return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(path)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
else {
|
||||
buildPaths(reusing: paths) // Re-build paths in the background
|
||||
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
|
||||
.store(in: &cancellable)
|
||||
|
||||
guard let path: [Snode] = paths.randomElement() else {
|
||||
return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(path)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
else {
|
||||
return buildPaths(reusing: [])
|
||||
.flatMap { paths in
|
||||
if let snode = snode {
|
||||
if let path = paths.filter({ !$0.contains(snode) }).randomElement() {
|
||||
return Just(path)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard let path: [Snode] = paths.randomElement() else {
|
||||
return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(path)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private static func dropGuardSnode(_ snode: Snode) {
|
||||
#if DEBUG
|
||||
|
@ -365,17 +613,66 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) }
|
||||
}
|
||||
|
||||
/// Builds an onion around `payload` and returns the result.
|
||||
private static func buildOnion(
|
||||
around payload: Data,
|
||||
targetedAt destination: OnionRequestAPIDestination
|
||||
) -> AnyPublisher<OnionBuildingResult, Error> {
|
||||
var guardSnode: Snode!
|
||||
var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination
|
||||
var encryptionResult: AESGCM.EncryptionResult!
|
||||
var snodeToExclude: Snode?
|
||||
|
||||
if case .snode(let snode) = destination { snodeToExclude = snode }
|
||||
|
||||
return getPath(excluding: snodeToExclude)
|
||||
.flatMap { path -> AnyPublisher<AESGCM.EncryptionResult, Error> in
|
||||
guardSnode = path.first!
|
||||
|
||||
// Encrypt in reverse order, i.e. the destination first
|
||||
return encrypt(payload, for: destination)
|
||||
.flatMap { r -> AnyPublisher<AESGCM.EncryptionResult, Error> in
|
||||
targetSnodeSymmetricKey = r.symmetricKey
|
||||
|
||||
// Recursively encrypt the layers of the onion (again in reverse order)
|
||||
encryptionResult = r
|
||||
var path = path
|
||||
var rhs = destination
|
||||
|
||||
func addLayer() -> AnyPublisher<AESGCM.EncryptionResult, Error> {
|
||||
guard !path.isEmpty else {
|
||||
return Just(encryptionResult)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let lhs = OnionRequestAPIDestination.snode(path.removeLast())
|
||||
return OnionRequestAPI
|
||||
.encryptHop(from: lhs, to: rhs, using: encryptionResult)
|
||||
.flatMap { r -> AnyPublisher<AESGCM.EncryptionResult, Error> in
|
||||
encryptionResult = r
|
||||
rhs = lhs
|
||||
return addLayer()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return addLayer()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.map { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Sends an onion request to `snode`. Builds new paths as needed.
|
||||
public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise<Data> {
|
||||
let payloadJson: JSON = [ "method" : method.rawValue, "params" : parameters ]
|
||||
|
||||
guard let payload: Data = try? JSONSerialization.data(withJSONObject: payloadJson, options: []) else {
|
||||
return Promise(error: HTTP.Error.invalidJSON)
|
||||
}
|
||||
|
||||
public static func sendOnionRequest(
|
||||
_ payload: Data,
|
||||
to snode: Snode
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
/// **Note:** Currently the service nodes only support V3 Onion Requests
|
||||
return sendOnionRequest(with: payload, to: OnionRequestAPIDestination.snode(snode), version: .v3)
|
||||
.map { _, maybeData in
|
||||
|
@ -391,160 +688,169 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
throw SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode, associatedWith: publicKey) ?? error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Sends an onion request to `server`. Builds new paths as needed.
|
||||
public static func sendOnionRequest(
|
||||
_ request: URLRequest,
|
||||
to server: String, // TODO: Remove this 'server' value (unused)
|
||||
with x25519PublicKey: String
|
||||
) -> Promise<(ResponseInfoType, Data?)> {
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
guard let url = request.url, let host = request.url?.host else {
|
||||
return Promise(error: OnionRequestAPIError.invalidURL)
|
||||
return Fail(error: OnionRequestAPIError.invalidURL)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let scheme: String? = url.scheme
|
||||
let port: UInt16? = url.port.map { UInt16($0) }
|
||||
|
||||
guard let payload: Data = generatePayload(for: request, with: version) else {
|
||||
return Promise(error: OnionRequestAPIError.invalidRequestInfo)
|
||||
guard let payload: Data = generateV4Payload(for: request) else {
|
||||
return Fail(error: OnionRequestAPIError.invalidRequestInfo)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let destination = OnionRequestAPIDestination.server(
|
||||
host: host,
|
||||
target: version.rawValue,
|
||||
x25519PublicKey: x25519PublicKey,
|
||||
scheme: scheme,
|
||||
port: port
|
||||
)
|
||||
let promise = sendOnionRequest(with: payload, to: destination, version: version)
|
||||
promise.catch2 { error in
|
||||
SNLog("Couldn't reach server: \(url) due to error: \(error).")
|
||||
}
|
||||
return promise
|
||||
return OnionRequestAPI
|
||||
.sendOnionRequest(
|
||||
with: payload,
|
||||
to: OnionRequestAPIDestination.server(
|
||||
host: host,
|
||||
target: OnionRequestAPIVersion.v4.rawValue,
|
||||
x25519PublicKey: x25519PublicKey,
|
||||
scheme: scheme,
|
||||
port: port
|
||||
),
|
||||
version: .v4
|
||||
)
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
SNLog("Couldn't reach server: \(url) due to error: \(error).")
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public static func sendOnionRequest(with payload: Data, to destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion) -> Promise<(OnionRequestResponseInfoType, Data?)> {
|
||||
let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending()
|
||||
|
||||
public static func sendOnionRequest(
|
||||
with payload: Data,
|
||||
to destination: OnionRequestAPIDestination,
|
||||
version: OnionRequestAPIVersion
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
var guardSnode: Snode?
|
||||
|
||||
Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths`
|
||||
buildOnion(around: payload, targetedAt: destination)
|
||||
.done2 { intermediate in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
|
||||
let finalEncryptionResult = intermediate.finalEncryptionResult
|
||||
let onion = finalEncryptionResult.ciphertext
|
||||
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
|
||||
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
|
||||
}
|
||||
let parameters: JSON = [
|
||||
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
]
|
||||
let body: Data
|
||||
do {
|
||||
body = try encode(ciphertext: onion, json: parameters)
|
||||
} catch {
|
||||
return seal.reject(error)
|
||||
}
|
||||
let destinationSymmetricKey = intermediate.destinationSymmetricKey
|
||||
|
||||
HTTP.execute(.post, url, body: body)
|
||||
.done2 { responseData in
|
||||
handleResponse(
|
||||
responseData: responseData,
|
||||
destinationSymmetricKey: destinationSymmetricKey,
|
||||
version: version,
|
||||
destination: destination,
|
||||
seal: seal
|
||||
)
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
return buildOnion(around: payload, targetedAt: destination)
|
||||
.subscribe(on: Threading.workQueue)
|
||||
.flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
|
||||
let finalEncryptionResult = intermediate.finalEncryptionResult
|
||||
let onion = finalEncryptionResult.ciphertext
|
||||
if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) {
|
||||
SNLog("Approaching request size limit: ~\(onion.count) bytes.")
|
||||
}
|
||||
.catch2 { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
promise.catch2 { error in // Must be invoked on Threading.workQueue
|
||||
guard case HTTP.Error.httpRequestFailed(let statusCode, let data) = error, let guardSnode = guardSnode else {
|
||||
return
|
||||
}
|
||||
|
||||
let path = paths.first { $0.contains(guardSnode) }
|
||||
|
||||
func handleUnspecificError() {
|
||||
guard let path = path else { return }
|
||||
let parameters: JSON = [
|
||||
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
]
|
||||
let destinationSymmetricKey = intermediate.destinationSymmetricKey
|
||||
|
||||
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?? 0
|
||||
pathFailureCount += 1
|
||||
|
||||
if pathFailureCount >= pathFailureThreshold {
|
||||
dropGuardSnode(guardSnode)
|
||||
path.forEach { snode in
|
||||
SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw
|
||||
// TODO: Replace 'json' with a codable typed
|
||||
return encode(ciphertext: onion, json: parameters)
|
||||
.flatMap { body in HTTP.execute(.post, url, body: body) }
|
||||
.flatMap { responseData in
|
||||
handleResponse(
|
||||
responseData: responseData,
|
||||
destinationSymmetricKey: destinationSymmetricKey,
|
||||
version: version,
|
||||
destination: destination
|
||||
)
|
||||
}
|
||||
|
||||
drop(path)
|
||||
}
|
||||
else {
|
||||
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let prefix = "Next node not found: "
|
||||
let json: JSON?
|
||||
|
||||
if let data: Data = data, let processedJson = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON {
|
||||
json = processedJson
|
||||
}
|
||||
else if let data: Data = data, let result: String = String(data: data, encoding: .utf8) {
|
||||
json = [ "result": result ]
|
||||
}
|
||||
else {
|
||||
json = nil
|
||||
}
|
||||
|
||||
if let message = json?["result"] as? String, message.hasPrefix(prefix) {
|
||||
let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..<message.endIndex]
|
||||
|
||||
if let path = path, let snode = path.first(where: { $0.ed25519PublicKey == ed25519PublicKey }) {
|
||||
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?? 0
|
||||
snodeFailureCount += 1
|
||||
|
||||
if snodeFailureCount >= snodeFailureThreshold {
|
||||
SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw
|
||||
do {
|
||||
try drop(snode)
|
||||
}
|
||||
catch {
|
||||
handleUnspecificError()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
guard
|
||||
case HTTPError.httpRequestFailed(let statusCode, let data) = error,
|
||||
let guardSnode: Snode = guardSnode
|
||||
else { return }
|
||||
|
||||
let path = paths.first { $0.contains(guardSnode) }
|
||||
|
||||
func handleUnspecificError() {
|
||||
guard let path = path else { return }
|
||||
|
||||
var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?? 0
|
||||
pathFailureCount += 1
|
||||
|
||||
if pathFailureCount >= pathFailureThreshold {
|
||||
dropGuardSnode(guardSnode)
|
||||
path.forEach { snode in
|
||||
SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw
|
||||
}
|
||||
|
||||
drop(path)
|
||||
}
|
||||
else {
|
||||
OnionRequestAPI.pathFailureCount[path] = pathFailureCount
|
||||
}
|
||||
}
|
||||
|
||||
let prefix = "Next node not found: "
|
||||
let json: JSON?
|
||||
|
||||
if let data: Data = data, let processedJson = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON {
|
||||
json = processedJson
|
||||
}
|
||||
else if let data: Data = data, let result: String = String(data: data, encoding: .utf8) {
|
||||
json = [ "result": result ]
|
||||
}
|
||||
else {
|
||||
json = nil
|
||||
}
|
||||
|
||||
if let message = json?["result"] as? String, message.hasPrefix(prefix) {
|
||||
let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..<message.endIndex]
|
||||
|
||||
if let path = path, let snode = path.first(where: { $0.ed25519PublicKey == ed25519PublicKey }) {
|
||||
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?? 0
|
||||
snodeFailureCount += 1
|
||||
|
||||
if snodeFailureCount >= snodeFailureThreshold {
|
||||
SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw
|
||||
do {
|
||||
try drop(snode)
|
||||
}
|
||||
catch {
|
||||
handleUnspecificError()
|
||||
}
|
||||
}
|
||||
else {
|
||||
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
|
||||
}
|
||||
} else {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
else if let message = json?["result"] as? String, message == "Loki Server error" {
|
||||
// Do nothing
|
||||
}
|
||||
else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 {
|
||||
// FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet
|
||||
handleUnspecificError()
|
||||
}
|
||||
else if statusCode == 0 { // Timeout
|
||||
// Do nothing
|
||||
}
|
||||
else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
}
|
||||
else {
|
||||
OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount
|
||||
}
|
||||
} else {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
else if let message = json?["result"] as? String, message == "Loki Server error" {
|
||||
// Do nothing
|
||||
}
|
||||
else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 {
|
||||
// FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet
|
||||
handleUnspecificError()
|
||||
}
|
||||
else if statusCode == 0 { // Timeout
|
||||
// Do nothing
|
||||
}
|
||||
else {
|
||||
handleUnspecificError()
|
||||
}
|
||||
}
|
||||
|
||||
return promise
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Version Handling
|
||||
|
@ -740,9 +1046,160 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
}
|
||||
}
|
||||
|
||||
public static func process(bencodedData data: Data) -> (info: ResponseInfo, body: Data?)? {
|
||||
// The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data
|
||||
// into parts to properly process it
|
||||
private static func handleResponse(
|
||||
responseData: Data,
|
||||
destinationSymmetricKey: Data,
|
||||
version: OnionRequestAPIVersion,
|
||||
destination: OnionRequestAPIDestination
|
||||
) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
|
||||
switch version {
|
||||
// V2 and V3 Onion Requests have the same structure for responses
|
||||
case .v2, .v3:
|
||||
let json: JSON
|
||||
|
||||
if let processedJson = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON {
|
||||
json = processedJson
|
||||
}
|
||||
else if let result: String = String(data: responseData, encoding: .utf8) {
|
||||
json = [ "result": result ]
|
||||
}
|
||||
else {
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else {
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey)
|
||||
|
||||
guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else {
|
||||
return Fail(error: HTTPError.invalidJSON)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if statusCode == 406 { // Clock out of sync
|
||||
SNLog("The user's clock is out of sync with the service node network.")
|
||||
return Fail(error: SnodeAPIError.clockOutOfSync)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if statusCode == 401 { // Signature verification failed
|
||||
SNLog("Failed to verify the signature.")
|
||||
return Fail(error: SnodeAPIError.signatureVerificationFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if let bodyAsString = json["body"] as? String {
|
||||
guard let bodyAsData = bodyAsString.data(using: .utf8) else {
|
||||
return Fail(error: HTTPError.invalidResponse)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let body = try? JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else {
|
||||
return Fail(
|
||||
error: OnionRequestAPIError.httpRequestFailedAtDestination(
|
||||
statusCode: UInt(statusCode),
|
||||
data: bodyAsData,
|
||||
destination: destination
|
||||
)
|
||||
).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if let timestamp = body["t"] as? Int64 {
|
||||
let offset = timestamp - Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
SnodeAPI.clockOffset.mutate { $0 = offset }
|
||||
}
|
||||
|
||||
guard 200...299 ~= statusCode else {
|
||||
return Fail(
|
||||
error: OnionRequestAPIError.httpRequestFailedAtDestination(
|
||||
statusCode: UInt(statusCode),
|
||||
data: bodyAsData,
|
||||
destination: destination
|
||||
)
|
||||
).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just((HTTP.ResponseInfo(code: statusCode, headers: [:]), bodyAsData))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard 200...299 ~= statusCode else {
|
||||
return Fail(
|
||||
error: OnionRequestAPIError.httpRequestFailedAtDestination(
|
||||
statusCode: UInt(statusCode),
|
||||
data: data,
|
||||
destination: destination
|
||||
)
|
||||
).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just((HTTP.ResponseInfo(code: statusCode, headers: [:]), data))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// V4 Onion Requests have a very different structure for responses
|
||||
case .v4:
|
||||
guard responseData.count >= AESGCM.ivSize else {
|
||||
return Fail(error: HTTPError.invalidResponse)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
do {
|
||||
let data: Data = try AESGCM.decrypt(responseData, with: destinationSymmetricKey)
|
||||
|
||||
// Process the bencoded response
|
||||
guard let processedResponse: (info: ResponseInfoType, body: Data?) = process(bencodedData: data) else {
|
||||
return Fail(error: HTTPError.invalidResponse)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Custom handle a clock out of sync error (v4 returns '425' but included the '406'
|
||||
// just in case)
|
||||
guard processedResponse.info.code != 406 && processedResponse.info.code != 425 else {
|
||||
SNLog("The user's clock is out of sync with the service node network.")
|
||||
return Fail(error: SnodeAPIError.clockOutOfSync)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
guard processedResponse.info.code != 401 else { // Signature verification failed
|
||||
SNLog("Failed to verify the signature.")
|
||||
return Fail(error: SnodeAPIError.signatureVerificationFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// Handle error status codes
|
||||
guard 200...299 ~= processedResponse.info.code else {
|
||||
return Fail(error: OnionRequestAPIError.httpRequestFailedAtDestination(
|
||||
statusCode: UInt(processedResponse.info.code),
|
||||
data: data,
|
||||
destination: destination
|
||||
)).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(processedResponse)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func process(bencodedData data: Data) -> (info: ResponseInfoType, body: Data?)? {
|
||||
// The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break
|
||||
// the data into parts to properly process it
|
||||
guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else {
|
||||
return nil
|
||||
}
|
||||
|
@ -761,7 +1218,8 @@ public enum OnionRequestAPI: OnionRequestAPIType {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case)
|
||||
// Custom handle a clock out of sync error (v4 returns '425' but included the '406' just
|
||||
// in case)
|
||||
guard responseInfo.code != 406 && responseInfo.code != 425 else { return nil }
|
||||
guard responseInfo.code != 401 else { return nil }
|
||||
|
||||
|
|
|
@ -59,4 +59,83 @@ public extension Publisher {
|
|||
|
||||
return sink(into: targetSubject, includeCompletions: includeCompletions)
|
||||
}
|
||||
|
||||
/// Automatically retains the subscription until it emits a 'completion' event
|
||||
func sinkUntilComplete(
|
||||
receiveCompletion: ((Subscribers.Completion<Failure>) -> Void)? = nil,
|
||||
receiveValue: ((Output) -> Void)? = nil
|
||||
) {
|
||||
var retainCycle: Cancellable? = nil
|
||||
retainCycle = self
|
||||
.sink(
|
||||
receiveCompletion: { result in
|
||||
receiveCompletion?(result)
|
||||
|
||||
// Redundant but without reading 'retainCycle' it will warn that the variable
|
||||
// isn't used
|
||||
if retainCycle != nil { retainCycle = nil }
|
||||
},
|
||||
receiveValue: (receiveValue ?? { _ in })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public extension AnyPublisher {
|
||||
/// Converts the publisher to output a Result instead of throwing an error, can be used to ensure a subscription never
|
||||
/// closes due to a failure
|
||||
func asResult() -> AnyPublisher<Result<Output, Failure>, Never> {
|
||||
self
|
||||
.map { Result<Output, Failure>.success($0) }
|
||||
.catch { Just(Result<Output, Failure>.failure($0)).eraseToAnyPublisher() }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Decoding
|
||||
|
||||
public extension AnyPublisher where Output == Data, Failure == Error {
|
||||
func decoded<R: Decodable>(
|
||||
as type: R.Type,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) -> AnyPublisher<R, Failure> {
|
||||
self
|
||||
.flatMap { data -> AnyPublisher<R, Error> in
|
||||
do {
|
||||
return Just(try data.decoded(as: type, using: dependencies))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
catch {
|
||||
return Fail(error: error)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure == Error {
|
||||
func decoded<R: Decodable>(
|
||||
as type: R.Type,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) -> AnyPublisher<(ResponseInfoType, R), Error> {
|
||||
self
|
||||
.flatMap { responseInfo, maybeData -> AnyPublisher<(ResponseInfoType, R), Error> in
|
||||
guard let data: Data = maybeData else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
do {
|
||||
return Just((responseInfo, try data.decoded(as: type, using: dependencies)))
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
catch {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -336,6 +336,26 @@ open class Storage {
|
|||
)
|
||||
}
|
||||
|
||||
open func writePublisher<T>(updates: @escaping (Database) throws -> T) -> AnyPublisher<T, Error> {
|
||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
||||
return Fail<T, Error>(error: StorageError.databaseInvalid)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return dbWriter.writePublisher(updates: updates)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
open func readPublisher<T>(value: @escaping (Database) throws -> T) -> AnyPublisher<T, Error> {
|
||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
||||
return Fail<T, Error>(error: StorageError.databaseInvalid)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return dbWriter.readPublisher(value: value)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@discardableResult public final func read<T>(_ value: (Database) throws -> T?) -> T? {
|
||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
||||
|
||||
|
@ -440,6 +460,20 @@ public extension Storage {
|
|||
|
||||
// MARK: - Combine Extensions
|
||||
|
||||
public extension Storage {
|
||||
func readPublisherFlatMap<T>(value: @escaping (Database) throws -> AnyPublisher<T, Error>) -> AnyPublisher<T, Error> {
|
||||
return readPublisher(value: value)
|
||||
.flatMap { resultPublisher -> AnyPublisher<T, Error> in resultPublisher }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func writePublisherFlatMap<T>(updates: @escaping (Database) throws -> AnyPublisher<T, Error>) -> AnyPublisher<T, Error> {
|
||||
return writePublisher(updates: updates)
|
||||
.flatMap { resultPublisher -> AnyPublisher<T, Error> in resultPublisher }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
public extension ValueObservation {
|
||||
func publisher(
|
||||
in storage: Storage,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
|
||||
public extension HTTP {
|
||||
// MARK: - Convenience Aliases
|
||||
|
@ -64,43 +64,66 @@ public extension Decodable {
|
|||
}
|
||||
}
|
||||
|
||||
public extension Promise where T == (ResponseInfoType, Data?) {
|
||||
func decoded(as types: HTTP.BatchResponseTypes, on queue: DispatchQueue? = nil, using dependencies: Dependencies = Dependencies()) -> Promise<HTTP.BatchResponse> {
|
||||
self.map(on: queue) { 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
|
||||
public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure == Error {
|
||||
func decoded(
|
||||
as types: HTTP.BatchResponseTypes,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) -> AnyPublisher<HTTP.BatchResponse, Error> {
|
||||
self
|
||||
.flatMap { responseInfo, maybeData -> AnyPublisher<HTTP.BatchResponse, Error> in
|
||||
// Need to split the data into an array of data so each item can be Decoded correctly
|
||||
guard let data: Data = maybeData else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let dataArray: [Data]
|
||||
|
||||
switch jsonObject {
|
||||
case let anyArray as [Any]:
|
||||
dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) }
|
||||
|
||||
guard dataArray.count == types.count else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
case let anyDict as [String: Any]:
|
||||
guard
|
||||
let resultsArray: [Data] = (anyDict["results"] as? [Any])?
|
||||
.compactMap({ try? JSONSerialization.data(withJSONObject: $0) }),
|
||||
resultsArray.count == types.count
|
||||
else {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
dataArray = resultsArray
|
||||
|
||||
default:
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
do {
|
||||
// TODO: Remove the 'Swift.'
|
||||
let result: HTTP.BatchResponse = try Swift.zip(dataArray, types)
|
||||
.map { data, type in try type.decoded(from: data, using: dependencies) }
|
||||
.map { data in (responseInfo, data) }
|
||||
|
||||
return Just(result)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
catch {
|
||||
return Fail(error: HTTPError.parsingFailed)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
let dataArray: [Data]
|
||||
|
||||
switch jsonObject {
|
||||
case let anyArray as [Any]:
|
||||
dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) }
|
||||
|
||||
guard 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) }),
|
||||
resultsArray.count == types.count
|
||||
else { throw HTTPError.parsingFailed }
|
||||
|
||||
dataArray = resultsArray
|
||||
|
||||
default: throw HTTPError.parsingFailed
|
||||
}
|
||||
|
||||
do {
|
||||
return try zip(dataArray, types)
|
||||
.map { data, type in try type.decoded(from: data, using: dependencies) }
|
||||
.map { data in (responseInfo, data) }
|
||||
}
|
||||
catch {
|
||||
throw HTTPError.parsingFailed
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,64 +66,25 @@ public enum HTTP {
|
|||
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verb
|
||||
|
||||
public enum Verb: String, Codable {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
public enum Error: LocalizedError, Equatable {
|
||||
case generic
|
||||
case invalidURL
|
||||
case invalidJSON
|
||||
case parsingFailed
|
||||
case invalidResponse
|
||||
case maxFileSizeExceeded
|
||||
case httpRequestFailed(statusCode: UInt, data: Data?)
|
||||
case timeout
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .generic: return "An error occurred."
|
||||
case .invalidURL: return "Invalid URL."
|
||||
case .invalidJSON: return "Invalid JSON."
|
||||
case .parsingFailed, .invalidResponse: return "Invalid response."
|
||||
case .maxFileSizeExceeded: return "Maximum file size exceeded."
|
||||
case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)."
|
||||
case .timeout: return "The request timed out."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main
|
||||
|
||||
public static func execute(_ verb: Verb, _ url: String, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
|
||||
return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
|
||||
public static func executeLegacy(
|
||||
_ method: HTTPMethod,
|
||||
_ url: String,
|
||||
timeout: TimeInterval = HTTP.defaultTimeout,
|
||||
useSeedNodeURLSession: Bool = false
|
||||
) -> Promise<Data> {
|
||||
return executeLegacy(method, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
|
||||
}
|
||||
|
||||
public static func execute(_ verb: Verb, _ url: String, parameters: JSON?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
|
||||
if let parameters = parameters {
|
||||
do {
|
||||
guard JSONSerialization.isValidJSONObject(parameters) else { return Promise(error: Error.invalidJSON) }
|
||||
let body = try JSONSerialization.data(withJSONObject: parameters, options: [ .fragmentsAllowed ])
|
||||
return execute(verb, url, body: body, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
|
||||
}
|
||||
catch (let error) {
|
||||
return Promise(error: error)
|
||||
}
|
||||
}
|
||||
else {
|
||||
return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
|
||||
}
|
||||
}
|
||||
|
||||
public static func execute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
|
||||
|
||||
public static func executeLegacy(
|
||||
_ method: HTTPMethod,
|
||||
_ url: String,
|
||||
body: Data?,
|
||||
timeout: TimeInterval = HTTP.defaultTimeout,
|
||||
useSeedNodeURLSession: Bool = false
|
||||
) -> Promise<Data> {
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.httpMethod = verb.rawValue
|
||||
request.httpBody = body
|
||||
|
@ -174,4 +135,84 @@ public enum HTTP {
|
|||
task.resume()
|
||||
return promise
|
||||
}
|
||||
|
||||
// MARK: - Execution
|
||||
|
||||
public static func execute(
|
||||
_ method: HTTPMethod,
|
||||
_ url: String,
|
||||
timeout: TimeInterval = HTTP.defaultTimeout,
|
||||
useSeedNodeURLSession: Bool = false
|
||||
) -> AnyPublisher<Data, Error> {
|
||||
return execute(
|
||||
method,
|
||||
url,
|
||||
body: nil,
|
||||
timeout: timeout,
|
||||
useSeedNodeURLSession: useSeedNodeURLSession
|
||||
)
|
||||
}
|
||||
|
||||
public static func execute(
|
||||
_ method: HTTPMethod,
|
||||
_ url: String,
|
||||
body: Data?, // TODO: Default Value?
|
||||
timeout: TimeInterval = HTTP.defaultTimeout,
|
||||
useSeedNodeURLSession: Bool = false
|
||||
) -> AnyPublisher<Data, Error> {
|
||||
guard let url: URL = URL(string: url) else {
|
||||
return Fail<Data, Error>(error: HTTPError.invalidURL)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let urlSession: URLSession = (useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = timeout
|
||||
request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent")
|
||||
request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value
|
||||
request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value
|
||||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: request)
|
||||
.mapError { error in
|
||||
SNLog("\(method.rawValue) request to \(url) failed due to error: \(error).")
|
||||
|
||||
// Override the actual error so that we can correctly catch failed requests
|
||||
// in sendOnionRequest(invoking:on:with:)
|
||||
switch (error as NSError).code {
|
||||
case NSURLErrorTimedOut: return HTTPError.timeout
|
||||
default: return HTTPError.httpRequestFailed(statusCode: 0, data: nil)
|
||||
}
|
||||
}
|
||||
.flatMap { data, response in
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
SNLog("\(method.rawValue) request to \(url) failed.")
|
||||
return Fail<Data, Error>(error: HTTPError.httpRequestFailed(statusCode: 0, data: data))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
let statusCode = UInt(response.statusCode)
|
||||
// TODO: Remove all the JSON handling?
|
||||
guard 200...299 ~= statusCode else {
|
||||
var json: JSON? = nil
|
||||
if let processedJson: JSON = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON {
|
||||
json = processedJson
|
||||
}
|
||||
else if let result: String = String(data: data, encoding: .utf8) {
|
||||
json = [ "result": result ]
|
||||
}
|
||||
|
||||
let jsonDescription: String = (json?.prettifiedDescription ?? "no debugging info provided")
|
||||
SNLog("\(method.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).")
|
||||
return Fail<Data, Error>(error: HTTPError.httpRequestFailed(statusCode: statusCode, data: data))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return Just(data)
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue