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:
Morgan Pretty 2022-11-28 08:32:32 +11:00
parent f5933bdf75
commit c9fdee9f24
62 changed files with 5919 additions and 2892 deletions

View File

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

View File

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

View File

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

View File

@ -1,3 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
final class ContextMenuWindow : UIWindow {

View File

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

View File

@ -485,7 +485,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
try MessageSender
.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
.sinkUntilComplete()
}
}

View File

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

View File

@ -721,7 +721,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
)
try MessageSender.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
.sinkUntilComplete()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import Sodium
import SessionUtilitiesKit

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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