diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 317003eb6..61968cf3f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -502,7 +502,6 @@ FD09797527FAB64300936362 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797327FAB3E200936362 /* ProfileManager.swift */; }; FD09797727FAB7A600936362 /* Data+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797627FAB7A600936362 /* Data+Image.swift */; }; FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797827FAB7E800936362 /* ImageFormat.swift */; }; - FD09797B27FBB25900936362 /* Updatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797A27FBB25900936362 /* Updatable.swift */; }; FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09797C27FBDB2000936362 /* Notification+Utilities.swift */; }; FD09798127FCFEE800936362 /* SessionThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798027FCFEE800936362 /* SessionThread.swift */; }; FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798227FD1A1500936362 /* ClosedGroup.swift */; }; @@ -591,6 +590,9 @@ FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */; }; + FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */; }; + FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; }; + FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; }; FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; }; FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; }; @@ -1656,7 +1658,6 @@ FD09797327FAB3E200936362 /* ProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManager.swift; sourceTree = ""; }; FD09797627FAB7A600936362 /* Data+Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Image.swift"; sourceTree = ""; }; FD09797827FAB7E800936362 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; - FD09797A27FBB25900936362 /* Updatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updatable.swift; sourceTree = ""; }; FD09797C27FBDB2000936362 /* Notification+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Utilities.swift"; sourceTree = ""; }; FD09798027FCFEE800936362 /* SessionThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThread.swift; sourceTree = ""; }; FD09798227FD1A1500936362 /* ClosedGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroup.swift; sourceTree = ""; }; @@ -1707,6 +1708,9 @@ FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigContactsSpec.swift; sourceTree = ""; }; + FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = ""; }; + FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = ""; }; + FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = ""; }; FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = ""; }; @@ -2550,7 +2554,6 @@ C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */, C33FDB1C255A580900E217F9 /* UIImage+OWS.h */, C33FDB81255A581100E217F9 /* UIImage+OWS.m */, - FD09797A27FBB25900936362 /* Updatable.swift */, ); path = Media; sourceTree = ""; @@ -3719,6 +3722,14 @@ path = Utilities; sourceTree = ""; }; + FD2B4B022949886900AB4848 /* Database */ = { + isa = PBXGroup; + children = ( + FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */, + ); + path = Database; + sourceTree = ""; + }; FD37E9C428A1C701003AE748 /* Themes */ = { isa = PBXGroup; children = ( @@ -4005,6 +4016,7 @@ FD8ECF7529340F4800C0D1BB /* LibSessionUtil */ = { isa = PBXGroup; children = ( + FD2B4B022949886900AB4848 /* Database */, FD8ECF8E29381FB200C0D1BB /* Config Handling */, FD8ECF7829340F7100C0D1BB /* libsession-util.xcframework */, FD8ECF882935AB7200C0D1BB /* SessionUtilError.swift */, @@ -4026,6 +4038,7 @@ isa = PBXGroup; children = ( FD8ECF8F29381FC200C0D1BB /* SessionUtil+UserProfile.swift */, + FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */, ); path = "Config Handling"; sourceTree = ""; @@ -4199,6 +4212,7 @@ FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, + FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */, ); path = Types; sourceTree = ""; @@ -5468,7 +5482,6 @@ FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */, FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, - FD09797B27FBB25900936362 /* Updatable.swift in Sources */, 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */, C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */, FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */, @@ -5623,7 +5636,9 @@ FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */, + FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */, 7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */, + FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, @@ -5728,6 +5743,7 @@ C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 766689672..92c50bb62 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1837,7 +1837,7 @@ extension ConversationVC: message: unsendRequest, threadId: threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey, namespace: .default) + to: .contact(publicKey: userPublicKey) ) } return @@ -1856,7 +1856,7 @@ extension ConversationVC: message: unsendRequest, threadId: threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey, namespace: .default) + to: .contact(publicKey: userPublicKey) ) } self?.showInputAccessoryView() @@ -2303,8 +2303,8 @@ extension ConversationVC { return } - Storage.shared.writeAsync( - updates: { db in + Storage.shared + .writePublisher { db in // If we aren't creating a new thread (ie. sending a message request) then send a // messageRequestResponse back to the sender (this allows the sender to know that // they have been approved and can now use this contact in closed groups) @@ -2321,21 +2321,22 @@ extension ConversationVC { } // Default 'didApproveMe' to true for the person approving the message request - try approvalData.contact - .with( - isApproved: true, - didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) + try approvalData.contact.save(db) + try Contact + .filter(id: approvalData.contact.id) + .updateAllAndConfig( + db, + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe + .set(to: approvalData.contact.didApproveMe || !isNewThread) ) - .save(db) - - - // Update the config with the approved contact - try MessageSender - .syncConfiguration(db, forceSyncNow: true) - .sinkUntilComplete() - }, - completion: { _, _ in updateNavigationBackStack() } - ) + } + .sinkUntilComplete( + receiveCompletion: { _ in + // Update the UI + updateNavigationBackStack() + } + ) } @objc func acceptMessageRequest() { diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 43134d1b3..c5a974041 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -481,11 +481,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { Storage.shared.writeAsync { db in try Contact .filter(id: threadId) - .updateAll(db, Contact.Columns.isBlocked.set(to: false)) - - try MessageSender - .syncConfiguration(db, forceSyncNow: true) - .sinkUntilComplete() + .updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false)) } } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 2f225fabe..77e4c617f 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -152,7 +152,7 @@ class ThreadSettingsViewModel: SessionTableViewModel (7 * 24 * 60 * 60) else { return } // Sync every 2 days Storage.shared - .writePublisherFlatMap { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) } - .sinkUntilComplete( - receiveCompletion: { result in + .writeAsync( + updates: { db in ConfigurationSyncJob.enqueue(db) }, + completion: { _, result in switch result { case .failure: break - case .finished: + case .success: // 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) diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index cf0fc7028..462d322d7 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -6,11 +6,25 @@ import SessionMessagingKit import SignalUtilitiesKit final class DisplayNameVC: BaseVC { + private let flow: Onboarding.Flow + private var spacer1HeightConstraint: NSLayoutConstraint! private var spacer2HeightConstraint: NSLayoutConstraint! private var registerButtonBottomOffsetConstraint: NSLayoutConstraint! private var bottomConstraint: NSLayoutConstraint! + // MARK: - Initialization + + init(flow: Onboarding.Flow) { + self.flow = flow + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Components private lazy var displayNameTextField: TextField = { @@ -176,11 +190,22 @@ final class DisplayNameVC: BaseVC { // Try to save the user name but ignore the result ProfileManager.updateLocal( queue: DispatchQueue.global(qos: .default), - profileName: displayName, - image: nil, - imageFilePath: nil + profileName: displayName ) - let pnModeVC = PNModeVC() + + // If we are not in the registration flow then we are finished and should go straight + // to the home screen + guard self.flow == .register else { + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } + + // Need to get the PN mode if registering + let pnModeVC = PNModeVC(flow: .register) navigationController?.pushViewController(pnModeVC, animated: true) } } diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index 9c25b8c1f..a142b17ff 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -91,10 +91,6 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont tabBarTopConstraint.constant = navigationController!.navigationBar.height() } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - General func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { @@ -154,32 +150,17 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont return } let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) - Onboarding.Flow.link.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - Identity.didRegister() + Onboarding.Flow.link + .preregister( + with: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) - // Now that we have registered get the Snode pool - GetSnodePoolJob.run() - - NotificationCenter.default.addObserver(self, selector: #selector(handleInitialConfigurationMessageReceived), name: .initialConfigurationMessageReceived, object: nil) - - ModalActivityIndicatorViewController - .present( - // There was some crashing here due to force-unwrapping so just falling back to - // using self if there is no nav controller - fromViewController: (self.navigationController ?? self) - ) { [weak self] modal in - self?.activityIndicatorModal = modal - } - } - - @objc private func handleInitialConfigurationMessageReceived(_ notification: Notification) { - DispatchQueue.main.async { - self.navigationController!.dismiss(animated: true) { - let pnModeVC = PNModeVC() - self.navigationController!.setViewControllers([ pnModeVC ], animated: true) - } - } + // Otherwise continue on to request push notifications permissions + let pnModeVC: PNModeVC = PNModeVC(flow: .link) + self.navigationController?.pushViewController(pnModeVC, animated: true) } } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 27c0687fe..0067fb033 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -1,58 +1,124 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import Sodium import GRDB import SessionUtilitiesKit import SessionMessagingKit enum Onboarding { + private static let profileNameRetrievalPublisher: Atomic> = { + let userPublicKey: String = getUserHexEncodedPublicKey() + + return Atomic( + SnodeAPI.getSwarm(for: userPublicKey) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { swarm -> AnyPublisher in + guard let snode = swarm.randomElement() else { + return Fail(error: SnodeAPIError.generic) + .eraseToAnyPublisher() + } + + return CurrentUserPoller.poll( + namespaces: [.configUserProfile], + from: snode, + for: userPublicKey, + on: DispatchQueue.global(qos: .userInitiated), + // Note: These values mean the received messages will be + // processed immediately rather than async as part of a Job + calledFromBackgroundPoller: true, + isBackgroundPollValid: { true } + ) + } + .flatMap { _ -> AnyPublisher in + Storage.shared.readPublisher { db in + try Profile + .filter(id: userPublicKey) + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + } + } + .shareReplay(1) + .eraseToAnyPublisher() + ) + }() + public static var profileNamePublisher: AnyPublisher { + profileNameRetrievalPublisher.wrappedValue + } enum Flow { case register, recover, link func preregister(with seed: Data, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) { - let userDefaults = UserDefaults.standard - Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey + // Create the initial shared util state (won't have been created on + // launch due to lack of ed25519 key) + SessionUtil.loadState( + userPublicKey: x25519PublicKey, + ed25519SecretKey: ed25519KeyPair.secretKey + ) + + // Store the user identity information Storage.shared.write { db in - try Contact(id: x25519PublicKey) - .with( - isApproved: true, - didApproveMe: true - ) - .save(db) + try Identity.store( + db, + seed: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) + // No need to show the seed again if the user is restoring or linking + db[.hasViewedSeed] = (self == .recover || self == .link) + + // Create a contact for the current user and set their approval/trusted statuses so + // they don't get weird behaviours + try Contact + .fetchOrCreate(db, id: x25519PublicKey) + .save(db) + try Contact + .filter(id: x25519PublicKey) + .updateAllAndConfig( + db, + Contact.Columns.isTrusted.set(to: true), // Always trust the current user + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + // Create the 'Note to Self' thread (not visible by default) try SessionThread .fetchOrCreate(db, id: x25519PublicKey, variant: .contact) .save(db) - - // Create the initial shared util state (won't have been created on - // launch due to lack of ed25519 key) - SessionUtil.loadState(ed25519SecretKey: ed25519KeyPair.secretKey) - - // No need to show the seed again if the user is restoring or linking - db[.hasViewedSeed] = (self == .recover || self == .link) } - + // Set hasSyncedInitialConfiguration to true so that when we hit the // home screen a configuration sync is triggered (yes, the logic is a // bit weird). This is needed so that if the user registers and // immediately links a device, there'll be a configuration in their swarm. - userDefaults[.hasSyncedInitialConfiguration] = (self == .register) + UserDefaults.standard[.hasSyncedInitialConfiguration] = (self == .register) - switch self { - case .register, .recover: - // Set both lastDisplayNameUpdate and lastProfilePictureUpdate to the - // current date, so that we don't overwrite what the user set in the - // display name step with whatever we find in their swarm. - userDefaults[.lastDisplayNameUpdate] = Date() - userDefaults[.lastProfilePictureUpdate] = Date() - - case .link: break - } + // Only continue if this isn't a new account + guard self != .register else { return } + + // Fetch the + Onboarding.profileNamePublisher.sinkUntilComplete() + } + + func completeRegistration() { + // Set the `lastDisplayNameUpdate` to the current date, so that we don't + // overwrite what the user set in the display name step with whatever we + // find in their swarm (otherwise the user could enter a display name and + // have it immediately overwritten due to the config request running slow) + UserDefaults.standard[.lastDisplayNameUpdate] = Date() + + // Notify the app that registration is complete + Identity.didRegister() + + // Now that we have registered get the Snode pool and sync push tokens + GetSnodePoolJob.run() + SyncPushTokensJob.run(uploadOnlyIfStale: false) } } } diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index 83751afed..8906f75cb 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -1,13 +1,15 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import SessionUIKit import SessionMessagingKit import SessionSnodeKit import SignalUtilitiesKit final class PNModeVC: BaseVC, OptionViewDelegate { - + private let flow: Onboarding.Flow + private var optionViews: [OptionView] { [ apnsOptionView, backgroundPollingOptionView ] } @@ -15,7 +17,19 @@ final class PNModeVC: BaseVC, OptionViewDelegate { private var selectedOptionView: OptionView? { return optionViews.first { $0.isSelected } } - + + // MARK: - Initialization + + init(flow: Onboarding.Flow) { + self.flow = flow + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Components private lazy var apnsOptionView: OptionView = { @@ -128,14 +142,68 @@ final class PNModeVC: BaseVC, OptionViewDelegate { } UserDefaults.standard[.isUsingFullAPNs] = (selectedOptionView == apnsOptionView) - Identity.didRegister() + // If we are registering then we can just continue on + guard flow != .register else { + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } - // Go to the home screen - let homeVC: HomeVC = HomeVC() - self.navigationController?.setViewControllers([ homeVC ], animated: true) + // Check if we already have a profile name (ie. profile retrieval completed while waiting on + // this screen) + let existingProfileName: String? = Storage.shared + .read { db in + try Profile + .filter(id: getUserHexEncodedPublicKey(db)) + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + } - // Now that we have registered get the Snode pool and sync push tokens - GetSnodePoolJob.run() - SyncPushTokensJob.run(uploadOnlyIfStale: false) + guard existingProfileName?.isEmpty != false else { + // If we have one then we can go straight to the home screen + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } + + // If we don't have one then show a loading indicator and try to retrieve the existing name + ModalActivityIndicatorViewController.present(fromViewController: self) { viewController in + Onboarding.profileNamePublisher + .timeout(.seconds(10), scheduler: DispatchQueue.main, customError: { HTTPError.timeout }) + .catch { _ -> AnyPublisher in + SNLog("Onboarding failed to retrieve existing profile information") + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveValue: { [weak self, flow = self.flow] value in + // Hide the loading indicator + viewController.dismiss(animated: true) + + // If we have no display name we need to collect one + guard value?.isEmpty == false else { + let displayNameVC: DisplayNameVC = DisplayNameVC(flow: flow) + self?.navigationController?.pushViewController(displayNameVC, animated: true) + return + } + + // Otherwise we are done and can go to the home screen + self?.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self?.navigationController?.setViewControllers([ homeVC ], animated: true) + } + ) + } } } diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 6d5ab3c94..b72382536 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -198,11 +198,18 @@ final class RegisterVC : BaseVC { animate() } - // MARK: Interaction + // MARK: - Interaction + @objc private func register() { - Onboarding.Flow.register.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - let displayNameVC = DisplayNameVC() - navigationController!.pushViewController(displayNameVC, animated: true) + Onboarding.Flow.register + .preregister( + with: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) + + let displayNameVC: DisplayNameVC = DisplayNameVC(flow: .register) + self.navigationController?.pushViewController(displayNameVC, animated: true) } @objc private func copyPublicKey() { diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index 0e6496757..85425966e 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -194,22 +194,33 @@ final class RestoreVC: BaseVC { present(modal, animated: true) } - let mnemonic = mnemonicTextView.text!.lowercased() + let seed: Data + let keyPairs: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) + do { - let hexEncodedSeed = try Mnemonic.decode(mnemonic: mnemonic) - let seed = Data(hex: hexEncodedSeed) - let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) - Onboarding.Flow.recover.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - mnemonicTextView.resignFirstResponder() - - Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in - let displayNameVC = DisplayNameVC() - self.navigationController!.pushViewController(displayNameVC, animated: true) - } - } catch let error { + let mnemonic: String = mnemonicTextView.text!.lowercased() + let hexEncodedSeed: String = try Mnemonic.decode(mnemonic: mnemonic) + seed = Data(hex: hexEncodedSeed) + keyPairs = try Identity.generate(from: seed) + } + catch let error { let error = error as? Mnemonic.DecodingError ?? Mnemonic.DecodingError.generic showError(title: error.errorDescription!) + return } + + // Load in the user config and progress to the next screen + mnemonicTextView.resignFirstResponder() + + Onboarding.Flow.recover + .preregister( + with: seed, + ed25519KeyPair: keyPairs.ed25519KeyPair, + x25519KeyPair: keyPairs.x25519KeyPair + ) + + let pnModeVC: PNModeVC = PNModeVC(flow: .recover) + self.navigationController?.pushViewController(pnModeVC, animated: true) } @objc private func handleLegalLabelTapped(_ tapGestureRecognizer: UITapGestureRecognizer) { diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 6c608c2bd..871bae502 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -246,10 +246,7 @@ class BlockedContactsViewModel: SessionTableViewModel Void - private let onImagePicked: (UIImage?, String?) -> Void + private let onImagePicked: (UIImage) -> Void + private let onImageFilePicked: (String) -> Void // MARK: - Initialization init( onTransition: @escaping (UIViewController, TransitionType) -> Void, - onImagePicked: @escaping (UIImage?, String?) -> Void + onImagePicked: @escaping (UIImage) -> Void, + onImageFilePicked: @escaping (String) -> Void ) { self.onTransition = onTransition self.onImagePicked = onImagePicked + self.onImageFilePicked = onImageFilePicked } // MARK: - UIImagePickerControllerDelegate @@ -44,14 +47,14 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati let viewController: CropScaleImageViewController = CropScaleImageViewController( srcImage: rawAvatar, successCompletion: { resultImage in - self?.onImagePicked(resultImage, nil) + self?.onImagePicked(resultImage) } ) self?.onTransition(viewController, .present) return } - self?.onImagePicked(nil, imageUrl.path) + self?.onImageFilePicked(imageUrl.path) } } } diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 34b0b84b5..0c7c2adf0 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -149,8 +149,7 @@ final class NukeDataModal: Modal { private func clearDeviceOnly() { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in - Storage.shared - .writePublisherFlatMap { db in try MessageSender.syncConfiguration(db, forceSyncNow: true) } + ConfigurationSyncJob.run() .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { _ in diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index a516d3447..6ea6c166c 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -70,13 +70,20 @@ class SettingsViewModel: SessionTableViewModel? = try SessionUtil.loadState( + for: .contacts, + secretKey: secretKey, + cachedData: nil + ) + let contactsConfResult: SessionUtil.ConfResult = try SessionUtil.upsert( + contactData: contactsData.map { ($0.contact.id, $0.contact, $0.profile) }, + in: Atomic(contactsConf) + ) + + if contactsConfResult.needsDump { + try SessionUtil + .createDump( + conf: contactsConf, + for: .contacts, + publicKey: userPublicKey, + messageHashes: nil + )? + .save(db) } Storage.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index d5e8704c6..3a3d07498 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -130,7 +130,7 @@ public extension BlindedIdLookup { if isCheckingForOutbox && !contact.isApproved { try Contact .filter(id: contact.id) - .updateAll(db, Contact.Columns.isApproved.set(to: true)) + .updateAllAndConfig(db, Contact.Columns.isApproved.set(to: true)) } break diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 524fd7300..6cc3b4fc5 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -4,6 +4,8 @@ import Foundation import GRDB import SessionUtilitiesKit +/// This type is duplicate in both the database and within the SessionUtil config so should only ever have it's data changes via the +/// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "contact" } internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) @@ -66,29 +68,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis } } -// MARK: - Convenience - -public extension Contact { - func with( - isTrusted: Updatable = .existing, - isApproved: Updatable = .existing, - isBlocked: Updatable = .existing, - didApproveMe: Updatable = .existing - ) -> Contact { - return Contact( - id: id, - isTrusted: ( - (isTrusted ?? self.isTrusted) || - self.id == getUserHexEncodedPublicKey() // Always trust ourselves - ), - isApproved: (isApproved ?? self.isApproved), - isBlocked: (isBlocked ?? self.isBlocked), - didApproveMe: (didApproveMe ?? self.didApproveMe), - hasBeenBlocked: ((isBlocked ?? self.isBlocked) || self.hasBeenBlocked) - ) - } -} - // MARK: - GRDB Interactions public extension Contact { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index a37a7bb2f..533c3657f 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -5,6 +5,8 @@ import GRDB import DifferenceKit import SessionUtilitiesKit +/// This type is duplicate in both the database and within the SessionUtil config so should only ever have it's data changes via the +/// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible, Differentiable { public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) @@ -160,26 +162,6 @@ public extension Profile { } } -// MARK: - Mutation - -public extension Profile { - func with( - name: String? = nil, - profilePictureUrl: Updatable = .existing, - profilePictureFileName: Updatable = .existing, - profileEncryptionKey: Updatable = .existing - ) -> Profile { - return Profile( - id: id, - name: (name ?? self.name), - nickname: self.nickname, - profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), - profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), - profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) - ) - } -} - // MARK: - GRDB Interactions public extension Profile { diff --git a/SessionMessagingKit/Database/Models/SharedConfigDump.swift b/SessionMessagingKit/Database/Models/SharedConfigDump.swift index ab22c481f..90b92befb 100644 --- a/SessionMessagingKit/Database/Models/SharedConfigDump.swift +++ b/SessionMessagingKit/Database/Models/SharedConfigDump.swift @@ -2,38 +2,81 @@ import Foundation import GRDB +import SessionSnodeKit import SessionUtilitiesKit -public struct ConfigDump: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "configDump" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case variant + case publicKey case data + case combinedMessageHashes } - public enum Variant: String, Codable, DatabaseValueConvertible, CaseIterable { + public enum Variant: String, Codable, DatabaseValueConvertible { case userProfile case contacts } - public var id: Variant { variant } - /// The type of config this dump is for public let variant: Variant + /// The public key for the swarm this dump is for + /// + /// **Note:** For user config items this will be an empty string + public let publicKey: String + /// The data for this dump public let data: Data + + /// A comma delimited array of message hashes for previously stored messages on the server + private let combinedMessageHashes: String? + + /// An array of message hashes for previously stored messages on the server + var messageHashes: [String]? { ConfigDump.messageHashes(from: combinedMessageHashes) } + + internal init( + variant: Variant, + publicKey: String, + data: Data, + messageHashes: [String]? + ) { + self.variant = variant + self.publicKey = publicKey + self.data = data + self.combinedMessageHashes = ConfigDump.combinedMessageHashes(from: messageHashes) + } } // MARK: - Convenience +public extension ConfigDump { + static func combinedMessageHashes(from messageHashes: [String]?) -> String? { + return messageHashes?.joined(separator: ",") + } + + static func messageHashes(from combinedMessageHashes: String?) -> [String]? { + return combinedMessageHashes?.components(separatedBy: ",") + } +} + public extension ConfigDump.Variant { + static let userVariants: [ConfigDump.Variant] = [ .userProfile, .contacts ] + var configMessageKind: SharedConfigMessage.Kind { switch self { case .userProfile: return .userProfile case .contacts: return .contacts } } + + var namespace: SnodeAPI.Namespace { + switch self { + case .userProfile: return SnodeAPI.Namespace.configUserProfile + case .contacts: return SnodeAPI.Namespace.configContacts + } + } } diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift new file mode 100644 index 000000000..2b734fffc --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -0,0 +1,355 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUtil +import SessionSnodeKit +import SessionUtilitiesKit + +public enum ConfigurationSyncJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + private static let maxRunFrequency: TimeInterval = 3 + + public static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard Features.useSharedUtilForUserConfig else { + success(job, true) + return + } + + // 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 + let pendingSwarmConfigChanges: [SingleDestinationChanges] = Storage.shared + .read({ db -> [SessionUtil.OutgoingConfResult]? in + guard + Identity.userExists(db), + let ed25519SecretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey + else { return nil } + + return try SessionUtil.pendingChanges( + db, + userPublicKey: getUserHexEncodedPublicKey(db), + ed25519SecretKey: ed25519SecretKey + ) + })? + .grouped(by: { $0.destination }) + .map({ (destination: Message.Destination, value: [SessionUtil.OutgoingConfResult]) -> SingleDestinationChanges in + SingleDestinationChanges( + destination: destination, + messages: value, + allOldHashes: value + .map { ($0.oldMessageHashes ?? []) } + .reduce([], +) + .asSet() + ) + }) + else { + failure(job, StorageError.generic, false) + return + } + + // If there are no pending changes then the job can just complete (next time something + // is updated we want to try and run immediately so don't scuedule another run in this case) + guard !pendingSwarmConfigChanges.isEmpty else { + success(job, true) + return + } + + Storage.shared + .readPublisher { db in + try pendingSwarmConfigChanges + .map { (change: SingleDestinationChanges) -> (messages: [TargetedMessage], allOldHashes: Set) in + ( + messages: try change.messages + .map { (outgoingConf: SessionUtil.OutgoingConfResult) -> TargetedMessage in + TargetedMessage( + sendData: try MessageSender.preparedSendData( + db, + message: outgoingConf.message, + to: change.destination, + interactionId: nil + ), + namespace: outgoingConf.namespace, + oldHashes: (outgoingConf.oldMessageHashes ?? []) + ) + }, + allOldHashes: change.allOldHashes + ) + } + } + .subscribe(on: queue) + .receive(on: queue) + .flatMap { (pendingSwarmChange: [(messages: [TargetedMessage], allOldHashes: Set)]) -> AnyPublisher<[HTTP.BatchResponse], Error> in + Publishers + .MergeMany( + pendingSwarmChange + .map { (messages: [TargetedMessage], oldHashes: Set) in + // Note: We do custom sending logic here because we want to batch the + // sending and deletion of messages within the same swarm + SnodeAPI + .sendConfigMessages( + messages + .compactMap { targetedMessage -> SnodeAPI.TargetedMessage? in + targetedMessage.sendData.snodeMessage + .map { ($0, targetedMessage.namespace) } + }, + oldHashes: Array(oldHashes) + ) + } + ) + .collect() + .eraseToAnyPublisher() + } + .map { (responses: [HTTP.BatchResponse]) -> [SuccessfulChange] in + // Process the response data into an easy to understand for (this isn't strictly + // needed but the code gets convoluted without this) + zip(responses, pendingSwarmConfigChanges) + .compactMap { (batchResponse: HTTP.BatchResponse, pendingSwarmChange: SingleDestinationChanges) -> [SuccessfulChange]? in + let maybePublicKey: String? = { + switch pendingSwarmChange.destination { + case .contact(let publicKey), .closedGroup(let publicKey): + return publicKey + + default: return nil + } + }() + + // If we don't have a publicKey then this is an invalid config + guard let publicKey: String = maybePublicKey else { return nil } + + // Need to know if we successfully deleted old messages (if we didn't then + // we want to keep the old hashes so we can delete them the next time) + let didDeleteOldConfigMessages: Bool = { + guard + let subResponse: HTTP.BatchSubResponse = (batchResponse.responses.last as? HTTP.BatchSubResponse), + 200...299 ~= subResponse.code + else { return false } + + return true + }() + + return zip(batchResponse.responses, pendingSwarmChange.messages) + .reduce(into: []) { (result: inout [SuccessfulChange], next: ResponseChange) in + // If the request wasn't successful then just ignore it (the next + // config sync will try make the changes again + guard + let subResponse: HTTP.BatchSubResponse = (next.response as? HTTP.BatchSubResponse), + 200...299 ~= subResponse.code, + let sendMessageResponse: SendMessagesResponse = subResponse.body + else { return } + + result.append( + SuccessfulChange( + message: next.change.message, + publicKey: publicKey, + updatedHashes: (didDeleteOldConfigMessages ? + [sendMessageResponse.hash] : + (next.change.oldMessageHashes ?? []) + .appending(sendMessageResponse.hash) + ) + ) + ) + } + } + .flatMap { $0 } + } + .map { (successfulChanges: [SuccessfulChange]) -> [ConfigDump] in + // Now that we have the successful changes, we need to mark them as pushed and + // generate any config dumps which need to be stored + successfulChanges + .compactMap { successfulChange -> ConfigDump? in + // Updating the pushed state returns a flag indicating whether the config + // needs to be dumped + guard SessionUtil.markAsPushed(message: successfulChange.message, publicKey: successfulChange.publicKey) else { + return nil + } + + let variant: ConfigDump.Variant = successfulChange.message.kind.configDumpVariant + let atomicConf: Atomic?> = SessionUtil.config( + for: variant, + publicKey: successfulChange.publicKey + ) + + return try? SessionUtil.createDump( + conf: atomicConf.wrappedValue, + for: variant, + publicKey: successfulChange.publicKey, + messageHashes: successfulChange.updatedHashes + ) + } + } + .sinkUntilComplete( + receiveValue: { (configDumps: [ConfigDump]) in + // Flag to indicate whether the job should be finished or will run again + var shouldFinishCurrentJob: Bool = false + + // Lastly we need to save the updated dumps to the database + let updatedJob: Job? = Storage.shared.write { db in + // Save the updated dumps to the database + try configDumps.forEach { try $0.save(db) } + + // When we complete the 'ConfigurationSync' job we want to immediately schedule + // another one with a 'nextRunTimestamp' set to the 'maxRunFrequency' value to + // throttle the config sync requests + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency) + + // If another 'ConfigurationSync' 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.configurationSync) + .fetchOne(db), + !JobRunner.isCurrentlyRunning(existingJob) + { + _ = try existingJob + .with(nextRunTimestamp: nextRunTimestamp) + .saved(db) + shouldFinishCurrentJob = true + return job + } + + return try job + .with(nextRunTimestamp: nextRunTimestamp) + .saved(db) + } + + success((updatedJob ?? job), shouldFinishCurrentJob) + } + ) + } +} + +// MARK: - Convenience Types + +public extension ConfigurationSyncJob { + fileprivate struct SingleDestinationChanges { + let destination: Message.Destination + let messages: [SessionUtil.OutgoingConfResult] + let allOldHashes: Set + } + + fileprivate struct TargetedMessage { + let sendData: MessageSender.PreparedSendData + let namespace: SnodeAPI.Namespace + let oldHashes: [String] + } + + typealias ResponseChange = (response: Codable, change: SessionUtil.OutgoingConfResult) + + fileprivate struct SuccessfulChange { + let message: SharedConfigMessage + let publicKey: String + let updatedHashes: [String] + } +} + +// MARK: - Convenience + +public extension ConfigurationSyncJob { + static func enqueue(_ db: Database? = nil) { + guard let db: Database = db else { + Storage.shared.writeAsync { ConfigurationSyncJob.enqueue($0) } + return + } + + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard Features.useSharedUtilForUserConfig else { + // 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 legacyConfigMessage: Message = try? ConfigurationMessage.getCurrent(db) + else { return } + + let publicKey: String = getUserHexEncodedPublicKey(db) + + JobRunner.add( + db, + job: Job( + variant: .messageSend, + threadId: publicKey, + details: MessageSendJob.Details( + destination: Message.Destination.contact(publicKey: publicKey), + message: legacyConfigMessage + ) + ) + ) + return + } + + // Upsert a config sync job (if there is already an pending one then no need + // to add another one) + JobRunner.upsert( + db, + job: ConfigurationSyncJob.createOrUpdateIfNeeded(db) + ) + } + + @discardableResult static func createOrUpdateIfNeeded(_ db: Database) -> Job { + // Try to get an existing job (if there is one that's not running) + if + let existingJob: Job = try? Job + .filter(Job.Columns.variant == Job.Variant.configurationSync) + .fetchOne(db), + !JobRunner.isCurrentlyRunning(existingJob) + { + return existingJob + } + + // Otherwise create a new job + return Job( + variant: .configurationSync, + behaviour: .recurring + ) + } + + static func run() -> AnyPublisher { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard Features.useSharedUtilForUserConfig else { + return Storage.shared + .writePublisher { db -> MessageSender.PreparedSendData in + // 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) else { throw StorageError.generic } + + let publicKey: String = getUserHexEncodedPublicKey(db) + + return try MessageSender.preparedSendData( + db, + message: try ConfigurationMessage.getCurrent(db), + to: Message.Destination.contact(publicKey: publicKey), + interactionId: nil + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .eraseToAnyPublisher() + } + + // Trigger the job emitting the result when completed + return Future { resolver in + ConfigurationSyncJob.run( + Job(variant: .configurationSync), + queue: DispatchQueue.global(qos: .userInitiated), + success: { _, _ in resolver(Result.success(())) }, + failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, + deferred: { _ in } + ) + } + .eraseToAnyPublisher() + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index d845f4eb5..e3ac101c8 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -33,7 +33,11 @@ public enum MessageReceiveJob: JobExecutor { Storage.shared.write { db in // Send any SharedConfigMessages to the SessionUtil to handle it - try SessionUtil.handleConfigMessages(db, messages: sharedConfigMessages) + try SessionUtil.handleConfigMessages( + db, + messages: sharedConfigMessages, + publicKey: (job.threadId ?? "") + ) // Handle the remaining messages var remainingMessagesToProcess: [Details.MessageInfo] = [] diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index e2fd0f7a2..020aa27fc 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -9,7 +9,7 @@ public enum SendReadReceiptsJob: JobExecutor { public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false - private static let minRunFrequency: TimeInterval = 3 + private static let maxRunFrequency: TimeInterval = 3 public static func run( _ job: Job, @@ -56,9 +56,9 @@ public enum SendReadReceiptsJob: JobExecutor { 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 + // 'maxRunFrequency' value to throttle the read receipt requests var shouldFinishCurrentJob: Bool = false - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency) + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency) let updatedJob: Job? = Storage.shared.write { db in // If another 'sendReadReceipts' job was scheduled then update that one @@ -163,7 +163,7 @@ public extension SendReadReceiptsJob { behaviour: .recurring, threadId: threadId, details: Details( - destination: .contact(publicKey: threadId, namespace: .default), + destination: .contact(publicKey: threadId), timestampMsValues: timestampMsValues.asSet() ) ) diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 5050c035a..43bab45c1 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -49,11 +49,8 @@ public enum UpdateProfilePictureJob: JobExecutor { ProfileManager.updateLocal( queue: queue, profileName: profile.name, - image: nil, - imageFilePath: profileFilePath, - success: { db, _ in - try MessageSender.syncConfiguration(db, forceSyncNow: true).sinkUntilComplete() - + avatarUpdate: (profileFilePath.map { .uploadFilePath($0) } ?? .none), + success: { db in // 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 // another database write diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift new file mode 100644 index 000000000..26ce1fce0 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -0,0 +1,366 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +internal extension SessionUtil { + // MARK: - Incoming Changes + + static func handleContactsUpdate( + _ db: Database, + in atomicConf: Atomic?>, + needsDump: Bool + ) throws { + typealias ContactData = [String: (contact: Contact, profile: Profile)] + + guard needsDump else { return } + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + + // Since we are doing direct memory manipulation we are using an `Atomic` type which has + // blocking access in it's `mutate` closure + let contactData: ContactData = atomicConf.mutate { conf -> ContactData in + var contactData: ContactData = [:] + var contact: contacts_contact = contacts_contact() + let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) + + while !contacts_iterator_done(contactIterator, &contact) { + let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + let contactResult: Contact = Contact( + id: contactId, + isApproved: contact.approved, + isBlocked: contact.blocked, + didApproveMe: contact.approved_me + ) + let profileResult: Profile = Profile( + id: contactId, + name: (contact.name.map { String(cString: $0) } ?? ""), + nickname: contact.nickname.map { String(cString: $0) }, + profilePictureUrl: contact.profile_pic.url.map { String(cString: $0) }, + profileEncryptionKey: (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ? + Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) : + nil + ) + ) + + contactData[contactId] = (contactResult, profileResult) + contacts_iterator_advance(contactIterator) + } + contacts_iterator_free(contactIterator) // Need to free the iterator + + return contactData + } + + // The current users contact data is handled separately so exclude it if it's present (as that's + // actually a bug) + let userPublicKey: String = getUserHexEncodedPublicKey() + let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey } + + // If we only updated the current user contact then no need to continue + guard !targetContactData.isEmpty else { return } + + // Since we don't sync 100% of the data stored against the contact and profile objects we + // need to only update the data we do have to ensure we don't overwrite anything that doesn't + // get synced + try targetContactData + .forEach { sessionId, data in + // Note: We only update the contact and profile records if the data has actually changed + // in order to avoid triggering UI updates for every thread on the home screen (the DB + // observation system can't differ between update calls which do and don't change anything) + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) + + if + (!data.profile.name.isEmpty && profile.name != data.profile.name) || + profile.nickname != data.profile.nickname || + profile.profilePictureUrl != data.profile.profilePictureUrl || + profile.profileEncryptionKey != data.profile.profileEncryptionKey + { + try profile.save(db) + try Profile + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (data.profile.name.isEmpty || profile.name == data.profile.name ? nil : + Profile.Columns.name.set(to: data.profile.name) + ), + (profile.nickname == data.profile.nickname ? nil : + Profile.Columns.nickname.set(to: data.profile.nickname) + ), + (profile.profilePictureUrl != data.profile.profilePictureUrl ? nil : + Profile.Columns.profilePictureUrl.set(to: data.profile.profilePictureUrl) + ), + (profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil : + Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey) + ) + ].compactMap { $0 } + ) + } + + /// Since message requests have no reverse, we should only handle setting `isApproved` + /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message + /// swapping `isApproved` and `didApproveMe` to `false` + if + (contact.isApproved != data.contact.isApproved) || + (contact.isBlocked != data.contact.isBlocked) || + (contact.didApproveMe != data.contact.didApproveMe) + { + try contact.save(db) + try Contact + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (!data.contact.isApproved ? nil : + Contact.Columns.isApproved.set(to: true) + ), + Contact.Columns.isBlocked.set(to: data.contact.isBlocked), + (!data.contact.didApproveMe ? nil : + Contact.Columns.didApproveMe.set(to: true) + ) + ].compactMap { $0 } + ) + } + } + } + + // MARK: - Outgoing Changes + + static func upsert( + contactData: [(id: String, contact: Contact?, profile: Profile?)], + in atomicConf: Atomic?> + ) throws -> ConfResult { + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + + // The current users contact data doesn't need to sync so exclude it + let userPublicKey: String = getUserHexEncodedPublicKey() + let targetContacts: [(id: String, contact: Contact?, profile: Profile?)] = contactData + .filter { $0.id != userPublicKey } + + // If we only updated the current user contact then no need to continue + guard !targetContacts.isEmpty else { return ConfResult(needsPush: false, needsDump: false) } + + // Since we are doing direct memory manipulation we are using an `Atomic` type which has + // blocking access in it's `mutate` closure + return atomicConf.mutate { conf in + // Update the name + targetContacts + .forEach { (id, maybeContact, maybeProfile) in + var sessionId: [CChar] = id + .bytes + .map { CChar(bitPattern: $0) } + var contact: contacts_contact = contacts_contact() + guard contacts_get_or_create(conf, &contact, &sessionId) else { + SNLog("Unable to upsert contact from Config Message") + return + } + + // Assign all properties to match the updated contact (if there is one) + if let updatedContact: Contact = maybeContact { + contact.approved = updatedContact.isApproved + contact.approved_me = updatedContact.didApproveMe + contact.blocked = updatedContact.isBlocked + } + + // Update the profile data (if there is one) + if let updatedProfile: Profile = maybeProfile { + /// Users we have sent a message request to may not have profile info in certain situations + /// + /// Note: We **MUST** store these in local variables rather than access them directly or they won't + /// exist in memory long enough to actually be assigned in the C type + let updatedName: [CChar]? = (updatedProfile.name.isEmpty ? + nil : + updatedProfile.name + .bytes + .map { CChar(bitPattern: $0) } + ) + let updatedNickname: [CChar]? = updatedProfile.nickname? + .bytes + .map { CChar(bitPattern: $0) } + let updatedAvatarUrl: [CChar]? = updatedProfile.profilePictureUrl? + .bytes + .map { CChar(bitPattern: $0) } + let updatedAvatarKey: [UInt8]? = updatedProfile.profileEncryptionKey? + .bytes + let oldAvatarUrl: String? = contact.profile_pic.url.map { String(cString: $0) } + let oldAvatarKey: Data? = (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ? + Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) : + nil + ) + updatedName?.withUnsafeBufferPointer { contact.name = $0.baseAddress } + (updatedNickname == nil ? + contact.nickname = nil : + updatedNickname?.withUnsafeBufferPointer { contact.nickname = $0.baseAddress } + ) + (updatedAvatarUrl == nil ? + contact.profile_pic.url = nil : + updatedAvatarUrl?.withUnsafeBufferPointer { + contact.profile_pic.url = $0.baseAddress + } + ) + (updatedAvatarKey == nil ? + contact.profile_pic.key = nil : + updatedAvatarKey?.withUnsafeBufferPointer { + contact.profile_pic.key = $0.baseAddress + } + ) + contact.profile_pic.keylen = (updatedAvatarKey?.count ?? 0) + + // Download the profile picture if needed + if oldAvatarUrl != updatedProfile.profilePictureUrl || oldAvatarKey != updatedProfile.profileEncryptionKey { + ProfileManager.downloadAvatar(for: updatedProfile) + } + } + + // Store the updated contact + contacts_set(conf, &contact) + } + + return ConfResult( + needsPush: config_needs_push(conf), + needsDump: config_needs_dump(conf) + ) + } + } +} + +// MARK: - Convenience + +internal extension SessionUtil { + static func updatingContacts(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedContacts: [Contact] = updated as? [Contact] else { throw StorageError.generic } + + // The current users contact data doesn't need to sync so exclude it + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let targetContacts: [Contact] = updatedContacts.filter { $0.id != userPublicKey } + + // If we only updated the current user contact then no need to continue + guard !targetContacts.isEmpty else { return updated } + + db.afterNextTransaction { db in + do { + let atomicConf: Atomic?> = SessionUtil.config( + for: .contacts, + publicKey: userPublicKey + ) + let result: ConfResult = try SessionUtil + .upsert( + contactData: targetContacts.map { (id: $0.id, contact: $0, profile: nil) }, + in: atomicConf + ) + + // If we don't need to dump the data the we can finish early + guard result.needsDump else { return } + + try SessionUtil.saveState( + db, + keepingExistingMessageHashes: true, + configDump: try atomicConf.mutate { conf in + try SessionUtil.createDump( + conf: conf, + for: .contacts, + publicKey: userPublicKey, + messageHashes: nil + ) + } + ) + } + catch { + SNLog("[libSession-util] Failed to dump updated data") + } + } + + return updated + } + + static func updatingProfiles(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedProfiles: [Profile] = updated as? [Profile] else { throw StorageError.generic } + + // We should only sync profiles which are associated to contact data to avoid including profiles + // for random people in community conversations so filter out any profiles which don't have an + // associated contact + let existingContactIds: [String] = (try? Contact + .filter(ids: updatedProfiles.map { $0.id }) + .select(.id) + .asRequest(of: String.self) + .fetchAll(db)) + .defaulting(to: []) + + // If none of the profiles are associated with existing contacts then ignore the changes (no need + // to do a config sync) + guard !existingContactIds.isEmpty else { return updated } + + // Get the user public key (updating their profile is handled separately + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + db.afterNextTransaction { db in + do { + // Update the user profile first (if needed) + if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) { + let atomicConf: Atomic?> = SessionUtil.config( + for: .userProfile, + publicKey: userPublicKey + ) + let result: ConfResult = try SessionUtil.update( + profile: updatedUserProfile, + in: atomicConf + ) + + if result.needsDump { + try SessionUtil.saveState( + db, + keepingExistingMessageHashes: true, + configDump: try atomicConf.mutate { conf in + try SessionUtil.createDump( + conf: conf, + for: .userProfile, + publicKey: userPublicKey, + messageHashes: nil + ) + } + ) + } + } + + // Then update other contacts + let atomicConf: Atomic?> = SessionUtil.config( + for: .contacts, + publicKey: userPublicKey + ) + let result: ConfResult = try SessionUtil + .upsert( + contactData: updatedProfiles + .filter { $0.id != userPublicKey } + .map { (id: $0.id, contact: nil, profile: $0) }, + in: atomicConf + ) + + // If we don't need to dump the data the we can finish early + guard result.needsDump else { return } + + try SessionUtil.saveState( + db, + keepingExistingMessageHashes: true, + configDump: try atomicConf.mutate { conf in + try SessionUtil.createDump( + conf: conf, + for: .contacts, + publicKey: userPublicKey, + messageHashes: nil + ) + } + ) + } + catch { + SNLog("[libSession-util] Failed to dump updated data") + } + } + + return updated + } +} diff --git a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift index 35fa57e27..d89132c58 100644 --- a/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -6,22 +6,24 @@ import SessionUtil import SessionUtilitiesKit internal extension SessionUtil { + // MARK: - Incoming Changes + static func handleUserProfileUpdate( _ db: Database, - in target: Target, + in atomicConf: Atomic?>, needsDump: Bool, latestConfigUpdateSentTimestamp: TimeInterval ) throws { typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) guard needsDump else { return } - guard target.conf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } let userPublicKey: String = getUserHexEncodedPublicKey(db) // Since we are doing direct memory manipulation we are using an `Atomic` type which has // blocking access in it's `mutate` closure - let maybeProfileData: ProfileData? = target.conf.mutate { conf -> ProfileData? in + let maybeProfileData: ProfileData? = atomicConf.mutate { conf -> ProfileData? in // A profile must have a name so if this is null then it's invalid and can be ignored guard let profileNamePtr: UnsafePointer = user_profile_get_name(conf) else { return nil @@ -52,33 +54,55 @@ internal extension SessionUtil { // Only save the data in the database if it's valid guard let profileData: ProfileData = maybeProfileData else { return } - // Profile (also force-approve the current user in case the account got into a weird state or - // restored directly from a migration) - try MessageReceiver.updateProfileIfNeeded( + // Handle user profile changes + try ProfileManager.updateProfileIfNeeded( db, publicKey: userPublicKey, name: profileData.profileName, - profilePictureUrl: profileData.profilePictureUrl, - profileKey: profileData.profilePictureKey, - sentTimestamp: latestConfigUpdateSentTimestamp + avatarUpdate: { + guard + let profilePictureUrl: String = profileData.profilePictureUrl, + let profileKey: Data = profileData.profilePictureKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), + sentTimestamp: latestConfigUpdateSentTimestamp, + calledFromConfigHandling: true ) - try Contact(id: userPublicKey) - .with( - isApproved: true, - didApproveMe: true - ) - .save(db) + + // Create a contact for the current user if needed (also force-approve the current user + // in case the account got into a weird state or restored directly from a migration) + let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) + + if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { + try userContact.save(db) + try Contact + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Contact.Columns.isTrusted.set(to: true), // Always trust the current user + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + } } - @discardableResult static func update( + // MARK: - Outgoing Changes + + static func update( profile: Profile, - in target: Target + in atomicConf: Atomic?> ) throws -> ConfResult { - guard target.conf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } + guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject } // Since we are doing direct memory manipulation we are using an `Atomic` type which has // blocking access in it's `mutate` closure - return target.conf.mutate { conf in + return atomicConf.mutate { conf in // Update the name user_profile_set_name(conf, profile.name) @@ -101,7 +125,7 @@ internal extension SessionUtil { user_profile_set_pic(conf, profilePic) } - return ( + return ConfResult( needsPush: config_needs_push(conf), needsDump: config_needs_dump(conf) ) diff --git a/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift new file mode 100644 index 000000000..2caf9c03c --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -0,0 +1,73 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +// MARK: - GRDB + +public extension QueryInterfaceRequest { + @discardableResult + func updateAllAndConfig( + _ db: Database, + _ assignments: ColumnAssignment... + ) throws -> Int { + return try updateAllAndConfig(db, assignments) + } + + @discardableResult + func updateAllAndConfig( + _ db: Database, + _ assignments: [ColumnAssignment] + ) throws -> Int { + switch self { + case let contactRequest as QueryInterfaceRequest: + return try contactRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count + + case let profileRequest as QueryInterfaceRequest: + return try profileRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count + + default: return try self.updateAll(db, assignments) + } + } +} + +public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord { + @discardableResult + func updateAndFetchAllAndUpdateConfig( + _ db: Database, + _ assignments: ColumnAssignment... + ) throws -> [RowDecoder] { + return try updateAndFetchAllAndUpdateConfig(db, assignments) + } + + @discardableResult + func updateAndFetchAllAndUpdateConfig( + _ db: Database, + _ assignments: [ColumnAssignment] + ) throws -> [RowDecoder] { + defer { + db.afterNextTransaction { db in + guard + self is QueryInterfaceRequest || + self is QueryInterfaceRequest || + self is QueryInterfaceRequest + else { return } + + // If we change one of these types then we may as well automatically enqueue + // a new config sync job once the transaction completes + ConfigurationSyncJob.enqueue(db) + } + } + + switch self { + case is QueryInterfaceRequest: + return try SessionUtil.updatingContacts(db, try updateAndFetchAll(db, assignments)) + + case is QueryInterfaceRequest: + return try SessionUtil.updatingProfiles(db, try updateAndFetchAll(db, assignments)) + + default: return try self.updateAndFetchAll(db, assignments) + } + } +} diff --git a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift index 7f295e39a..d995a571f 100644 --- a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift @@ -2,73 +2,94 @@ import Foundation import GRDB +import SessionSnodeKit import SessionUtil import SessionUtilitiesKit -/*internal*/public enum SessionUtil { - public typealias ConfResult = (needsPush: Bool, needsDump: Bool) - public typealias IncomingConfResult = (needsPush: Bool, needsDump: Bool, latestSentTimestamp: TimeInterval) +public enum SessionUtil { + public struct ConfResult { + let needsPush: Bool + let needsDump: Bool + } - enum Target { - case global(variant: ConfigDump.Variant) - case custom(conf: Atomic?>) - - var conf: Atomic?> { - switch self { - case .global(let variant): return SessionUtil.config(for: variant) - case .custom(let conf): return conf - } - } + public struct IncomingConfResult { + let needsPush: Bool + let needsDump: Bool + let messageHashes: [String] + let latestSentTimestamp: TimeInterval + } + + public struct OutgoingConfResult { + let message: SharedConfigMessage + let namespace: SnodeAPI.Namespace + let destination: Message.Destination + let oldMessageHashes: [String]? } // MARK: - Configs - private static var userProfileConfig: Atomic?> = Atomic(nil) - private static var contactsConfig: Atomic?> = Atomic(nil) + fileprivate static var configStore: Atomic<[ConfigKey: Atomic?>]> = Atomic([:]) + + public static func config(for variant: ConfigDump.Variant, publicKey: String) -> Atomic?> { + let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey) + + return ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) + } // MARK: - Variables + /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been + /// loaded yet (eg. fresh install) public static var needsSync: Bool { - return ConfigDump.Variant.allCases.contains { variant in - switch variant { - case .userProfile: - return (userProfileConfig.wrappedValue.map { config_needs_push($0) } ?? false) - - case .contacts: - return (contactsConfig.wrappedValue.map { config_needs_push($0) } ?? false) - } - } - } - - // MARK: - Convenience - private static func config(for variant: ConfigDump.Variant) -> Atomic?> { - switch variant { - case .userProfile: return SessionUtil.userProfileConfig - case .contacts: return SessionUtil.contactsConfig - } + return configStore + .wrappedValue + .contains { _, atomicConf in config_needs_push(atomicConf.wrappedValue) } } // MARK: - Loading - /*internal*/public static func loadState(ed25519SecretKey: [UInt8]?) { + public static func loadState( + userPublicKey: String, + ed25519SecretKey: [UInt8]? + ) { guard let secretKey: [UInt8] = ed25519SecretKey else { return } - SessionUtil.userProfileConfig.mutate { $0 = loadState(for: .userProfile, secretKey: secretKey) } - SessionUtil.contactsConfig.mutate { $0 = loadState(for: .contacts, secretKey: secretKey) } - } - - private static func loadState( - for variant: ConfigDump.Variant, - secretKey ed25519SecretKey: [UInt8]? - ) -> UnsafeMutablePointer? { - guard let secretKey: [UInt8] = ed25519SecretKey else { return nil } + // Retrieve the existing dumps from the database + let existingDumps: Set = Storage.shared + .read { db in try ConfigDump.fetchSet(db) } + .defaulting(to: []) + let existingDumpVariants: Set = existingDumps + .map { $0.variant } + .asSet() + let missingRequiredVariants: Set = ConfigDump.Variant.userVariants + .asSet() + .subtracting(existingDumpVariants) - // Load any - let storedDump: Data? = Storage.shared - .read { db in try ConfigDump.fetchOne(db, id: variant) }? - .data - - return try? loadState(for: variant, secretKey: secretKey, cachedData: storedDump) + // Create the 'config_object' records for each dump + SessionUtil.configStore.mutate { confStore in + existingDumps.forEach { dump in + confStore[ConfigKey(variant: dump.variant, publicKey: dump.publicKey)] = Atomic( + try? SessionUtil.loadState( + for: dump.variant, + secretKey: secretKey, + cachedData: dump.data + ) + ) + } + + missingRequiredVariants.forEach { variant in + confStore[ConfigKey(variant: variant, publicKey: userPublicKey)] = Atomic( + try? SessionUtil.loadState( + for: variant, + secretKey: secretKey, + cachedData: nil + ) + ) + } + } } internal static func loadState( @@ -117,87 +138,165 @@ import SessionUtilitiesKit internal static func saveState( _ db: Database, - conf: UnsafeMutablePointer?, - for variant: ConfigDump.Variant + keepingExistingMessageHashes: Bool, + configDump: ConfigDump? ) throws { + guard let configDump: ConfigDump = configDump else { return } + + // If we want to keep the existing message hashes then we need + // to fetch them from the db and create a new 'ConfigDump' instance + let targetDump: ConfigDump = try { + guard keepingExistingMessageHashes else { return configDump } + + let existingCombinedMessageHashes: String? = try ConfigDump + .filter( + ConfigDump.Columns.variant == configDump.variant && + ConfigDump.Columns.publicKey == configDump.publicKey + ) + .select(.combinedMessageHashes) + .asRequest(of: String.self) + .fetchOne(db) + + return ConfigDump( + variant: configDump.variant, + publicKey: configDump.publicKey, + data: configDump.data, + messageHashes: ConfigDump.messageHashes(from: existingCombinedMessageHashes) + ) + }() + + // Actually save the dump + try targetDump.save(db) + } + + internal static func createDump( + conf: UnsafeMutablePointer?, + for variant: ConfigDump.Variant, + publicKey: String, + messageHashes: [String]? + ) throws -> ConfigDump? { guard conf != nil else { throw SessionUtilError.nilConfigObject } // If it doesn't need a dump then do nothing - guard config_needs_dump(conf) else { return } + guard config_needs_dump(conf) else { return nil } var dumpResult: UnsafeMutablePointer? = nil var dumpResultLen: Int = 0 config_dump(conf, &dumpResult, &dumpResultLen) - guard let dumpResult: UnsafeMutablePointer = dumpResult else { return } + guard let dumpResult: UnsafeMutablePointer = dumpResult else { return nil } let dumpData: Data = Data(bytes: dumpResult, count: dumpResultLen) dumpResult.deallocate() - try ConfigDump( + return ConfigDump( variant: variant, - data: dumpData + publicKey: publicKey, + data: dumpData, + messageHashes: messageHashes ) - .save(db) } // MARK: - Pushes - public static func getChanges( - for variants: [ConfigDump.Variant] = ConfigDump.Variant.allCases, + public static func pendingChanges( + _ db: Database, + userPublicKey: String, ed25519SecretKey: [UInt8] - ) -> [SharedConfigMessage] { - return variants - .compactMap { variant -> SharedConfigMessage? in - let conf = SessionUtil.config(for: variant) + ) throws -> [OutgoingConfResult] { + let existingDumpInfo: Set = try ConfigDump + .select(.variant, .publicKey, .combinedMessageHashes) + .asRequest(of: DumpInfo.self) + .fetchSet(db) + + // Ensure we always check the required user config types for changes even if there is no dump + // data yet (to deal with first launch cases) + return existingDumpInfo + .inserting( + contentsOf: DumpInfo.requiredUserConfigDumpInfo(userPublicKey: userPublicKey) + .filter { requiredInfo -> Bool in + !existingDumpInfo.contains(where: { + $0.variant == requiredInfo.variant && + $0.publicKey == requiredInfo.publicKey + }) + } + ) + .compactMap { dumpInfo -> OutgoingConfResult? in + let key: ConfigKey = ConfigKey(variant: dumpInfo.variant, publicKey: dumpInfo.publicKey) + let atomicConf: Atomic?> = ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) // Check if the config needs to be pushed - guard config_needs_push(conf.wrappedValue) else { return nil } + guard config_needs_push(atomicConf.wrappedValue) else { return nil } var toPush: UnsafeMutablePointer? = nil var toPushLen: Int = 0 - let seqNo: Int64 = conf.mutate { config_push($0, &toPush, &toPushLen) } + let seqNo: Int64 = atomicConf.mutate { config_push($0, &toPush, &toPushLen) } guard let toPush: UnsafeMutablePointer = toPush else { return nil } let pushData: Data = Data(bytes: toPush, count: toPushLen) toPush.deallocate() - return SharedConfigMessage( - kind: variant.configMessageKind, - seqNo: seqNo, - data: pushData + return OutgoingConfResult( + message: SharedConfigMessage( + kind: dumpInfo.variant.configMessageKind, + seqNo: seqNo, + data: pushData + ), + namespace: dumpInfo.variant.namespace, + destination: (dumpInfo.publicKey == userPublicKey ? + Message.Destination.contact(publicKey: userPublicKey) : + Message.Destination.closedGroup(groupPublicKey: dumpInfo.publicKey) + ), + oldMessageHashes: dumpInfo.messageHashes ) } } - public static func markAsPushed(messages: [SharedConfigMessage]) -> [ConfigDump.Variant: Bool] { - messages.reduce(into: [:]) { result, message in - let conf = SessionUtil.config(for: message.kind.configDumpVariant) - - // Mark the config as pushed - config_confirm_pushed(conf.wrappedValue, message.seqNo) - - // Update the result to indicate whether the config needs to be dumped - result[message.kind.configDumpVariant] = config_needs_dump(conf.wrappedValue) - } + public static func markAsPushed( + message: SharedConfigMessage, + publicKey: String + ) -> Bool { + let key: ConfigKey = ConfigKey(variant: message.kind.configDumpVariant, publicKey: publicKey) + let atomicConf: Atomic?> = ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) + + // Mark the config as pushed + config_confirm_pushed(atomicConf.wrappedValue, message.seqNo) + + // Update the result to indicate whether the config needs to be dumped + return config_needs_dump(atomicConf.wrappedValue) } // MARK: - Receiving public static func handleConfigMessages( _ db: Database, - messages: [SharedConfigMessage] + messages: [SharedConfigMessage], + publicKey: String ) throws { + guard !messages.isEmpty else { return } + guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } + let groupedMessages: [SharedConfigMessage.Kind: [SharedConfigMessage]] = messages .grouped(by: \.kind) // Merge the config messages into the current state let results: [ConfigDump.Variant: IncomingConfResult] = groupedMessages .reduce(into: [:]) { result, next in - let atomicConf = SessionUtil.config(for: next.key.configDumpVariant) + let key: ConfigKey = ConfigKey(variant: next.key.configDumpVariant, publicKey: publicKey) + let atomicConf: Atomic?> = ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) var needsPush: Bool = false var needsDump: Bool = false + let messageHashes: [String] = next.value.compactMap { $0.serverHash } let messageSentTimestamp: TimeInterval = TimeInterval( (next.value.compactMap { $0.sentTimestamp }.max() ?? 0) / 1000 ) @@ -217,25 +316,119 @@ import SessionUtilitiesKit } // Return the current state of the config - result[next.key.configDumpVariant] = ( + result[next.key.configDumpVariant] = IncomingConfResult( needsPush: needsPush, needsDump: needsDump, + messageHashes: messageHashes, latestSentTimestamp: messageSentTimestamp ) } - // If the data needs to be dumped then apply the relevant local changes + // Process the results from the merging try results.forEach { variant, result in + let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey) + let atomicConf: Atomic?> = ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) + + // Apply the updated states to the database switch variant { case .userProfile: try SessionUtil.handleUserProfileUpdate( db, - in: .global(variant: variant), + in: atomicConf, needsDump: result.needsDump, latestConfigUpdateSentTimestamp: result.latestSentTimestamp ) + + case .contacts: + try SessionUtil.handleContactsUpdate( + db, + in: atomicConf, + needsDump: result.needsDump + ) } + + // We need to get the existing message hashes and combine them with the latest from the + // service node to ensure the next push will properly clean up old messages + let oldMessageHashes: Set = try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == publicKey + ) + .select(.combinedMessageHashes) + .asRequest(of: String.self) + .fetchOne(db) + .map { ConfigDump.messageHashes(from: $0) } + .defaulting(to: []) + .asSet() + let allMessageHashes: [String] = Array(oldMessageHashes + .inserting(contentsOf: result.messageHashes.asSet())) + let messageHashesChanged: Bool = (oldMessageHashes != result.messageHashes.asSet()) + + // Now that the changes are applied, update the cached dumps + switch (result.needsDump, messageHashesChanged) { + case (true, _): + // The config data had changes so regenerate the dump and save it + try atomicConf + .mutate { conf -> ConfigDump? in + try SessionUtil.createDump( + conf: conf, + for: variant, + publicKey: publicKey, + messageHashes: allMessageHashes + ) + }? + .save(db) + + case (false, true): + // The config data didn't change but there were different messages on the service node + // so just update the message hashes so the next sync can properly remove any old ones + try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == publicKey + ) + .updateAll( + db, + ConfigDump.Columns.combinedMessageHashes + .set(to: ConfigDump.combinedMessageHashes(from: allMessageHashes)) + ) + + default: break + } + + // Now that the local state has been updated, trigger a config sync (this will push any + // pending updates and properly update the state) + if results.contains(where: { $0.value.needsPush }) { + ConfigurationSyncJob.enqueue(db) } } } + +// MARK: - Internal Convenience + +fileprivate extension SessionUtil { + struct ConfigKey: Hashable { + let variant: ConfigDump.Variant + let publicKey: String + } + + struct DumpInfo: FetchableRecord, Decodable, Hashable { + let variant: ConfigDump.Variant + let publicKey: String + private let combinedMessageHashes: String? + + var messageHashes: [String]? { ConfigDump.messageHashes(from: combinedMessageHashes) } + + // MARK: - Convenience + + static func requiredUserConfigDumpInfo(userPublicKey: String) -> Set { + return ConfigDump.Variant.userVariants + .map { DumpInfo(variant: $0, publicKey: userPublicKey, combinedMessageHashes: nil) } + .asSet() + } + } +} diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 40211e90f..3714b06c6 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -6,15 +6,9 @@ import SessionSnodeKit import SessionUtilitiesKit public extension Message { - enum Destination: Codable { - case contact( - publicKey: String, - namespace: SnodeAPI.Namespace - ) - case closedGroup( - groupPublicKey: String, - namespace: SnodeAPI.Namespace - ) + enum Destination: Codable, Hashable { + case contact(publicKey: String) + case closedGroup(groupPublicKey: String) case openGroup( roomToken: String, server: String, @@ -23,13 +17,6 @@ public extension Message { fileIds: [String]? = nil ) case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) - - var namespace: SnodeAPI.Namespace { - switch self { - case .contact(_, let namespace), .closedGroup(_, let namespace): return namespace - default: preconditionFailure("Attepted to retrieve namespace for invalid destination") - } - } public static func from( _ db: Database, @@ -50,10 +37,10 @@ public extension Message { ) } - return .contact(publicKey: thread.id, namespace: .default) + return .contact(publicKey: thread.id) case .closedGroup: - return .closedGroup(groupPublicKey: thread.id, namespace: .legacyClosedGroup) + return .closedGroup(groupPublicKey: thread.id) case .openGroup: guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else { @@ -79,59 +66,5 @@ public extension Message { default: return self } } - - // MARK: - Codable - - // FIXME: Remove this custom implementation after enough time has passed (added the 'namespace' properties) - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - // Should only have a single root key so we can just switch on it to have cleaner code - switch container.allKeys.first { - case .contact: - let childContainer: KeyedDecodingContainer = try container.nestedContainer(keyedBy: ContactCodingKeys.self, forKey: .contact) - - self = .contact( - publicKey: try childContainer.decode(String.self, forKey: .publicKey), - namespace: ( - (try? childContainer.decode(SnodeAPI.Namespace.self, forKey: .namespace)) ?? - .default - ) - ) - - case .closedGroup: - let childContainer: KeyedDecodingContainer = try container.nestedContainer(keyedBy: ClosedGroupCodingKeys.self, forKey: .closedGroup) - - self = .closedGroup( - groupPublicKey: try childContainer.decode(String.self, forKey: .groupPublicKey), - namespace: ( - (try? childContainer.decode(SnodeAPI.Namespace.self, forKey: .namespace)) ?? - .legacyClosedGroup - ) - ) - - case .openGroup: - let childContainer: KeyedDecodingContainer = try container.nestedContainer(keyedBy: OpenGroupCodingKeys.self, forKey: .openGroup) - - self = .openGroup( - roomToken: try childContainer.decode(String.self, forKey: .roomToken), - server: try childContainer.decode(String.self, forKey: .server), - whisperTo: try? childContainer.decode(String.self, forKey: .whisperTo), - whisperMods: try childContainer.decode(Bool.self, forKey: .whisperMods), - fileIds: try? childContainer.decode([String].self, forKey: .fileIds) - ) - - case .openGroupInbox: - let childContainer: KeyedDecodingContainer = try container.nestedContainer(keyedBy: OpenGroupInboxCodingKeys.self, forKey: .openGroupInbox) - - self = .openGroupInbox( - server: try childContainer.decode(String.self, forKey: .server), - openGroupPublicKey: try childContainer.decode(String.self, forKey: .openGroupPublicKey), - blindedPublicKey: try childContainer.decode(String.self, forKey: .blindedPublicKey) - ) - - default: throw MessageReceiverError.invalidMessage - } - } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index dc31d502e..3ea92ad34 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -183,7 +183,7 @@ public extension Message { default: return false } - case is ConfigurationMessage: return true + case is ConfigurationMessage, is SharedConfigMessage: return true case is UnsendRequest: return true default: return false } diff --git a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift index 5d97b576f..c44fe7aee 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift @@ -108,15 +108,18 @@ internal extension AnyPublisher where Output == HTTP.BatchResponse, Failure == E func map( requests: [OpenGroupAPI.BatchRequest.Info], toHashMapFor endpointType: E.Type - ) -> AnyPublisher<[E: (ResponseInfoType, Codable?)], Error> { + ) -> AnyPublisher<(info: ResponseInfoType, data: [E: 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 - } + .map { result -> (info: ResponseInfoType, data: [E: Codable]) in + ( + info: result.info, + data: result.responses.enumerated() + .reduce(into: [:]) { prev, next in + guard let endpoint: E = requests[next.offset].endpoint as? E else { return } + + prev[endpoint] = next.element + } + ) } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 688ec61fb..69224a0ea 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -32,7 +32,7 @@ public enum OpenGroupAPI { hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> { + ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) .filter(OpenGroup.Columns.server == server) @@ -152,7 +152,7 @@ public enum OpenGroupAPI { server: String, requests: [BatchRequest.Info], using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> { + ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } return OpenGroupAPI @@ -184,7 +184,7 @@ public enum OpenGroupAPI { server: String, requests: [BatchRequest.Info], using dependencies: SMKDependencies = SMKDependencies() - ) -> AnyPublisher<[Endpoint: (ResponseInfoType, Codable?)], Error> { + ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } return OpenGroupAPI @@ -339,10 +339,9 @@ public enum OpenGroupAPI { requests: requestResponseType, using: dependencies ) - .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)?.body) } - let maybeRoomResponse: (ResponseInfoType, Codable?)? = response + .flatMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room)), Error> in + let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) + let maybeRoomResponse: Codable? = data .first(where: { key, _ in switch key { case .room: return true @@ -350,14 +349,13 @@ public enum OpenGroupAPI { } }) .map { _, value in value } - let maybeRoom: (info: ResponseInfoType, data: Room?)? = maybeRoomResponse - .map { info, data in (info, (data as? HTTP.BatchSubResponse)?.body) } + let maybeRoom: HTTP.BatchSubResponse? = (maybeRoomResponse as? HTTP.BatchSubResponse) guard - let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info, - let capabilities: Capabilities = maybeCapabilities?.data, - let roomInfo: ResponseInfoType = maybeRoom?.info, - let room: Room = maybeRoom?.data + let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, + let capabilities: Capabilities = maybeCapabilities?.body, + let roomInfo: ResponseInfoType = maybeRoom?.responseInfo, + let room: Room = maybeRoom?.body else { return Fail(error: HTTPError.parsingFailed) .eraseToAnyPublisher() @@ -407,25 +405,22 @@ public enum OpenGroupAPI { requests: requestResponseType, using: dependencies ) - .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)?.body) } - let maybeRoomResponse: (ResponseInfoType, Codable?)? = response + .flatMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> in + let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) + let maybeRooms: HTTP.BatchSubResponse<[Room]>? = data .first(where: { key, _ in switch key { case .rooms: return true default: return false } }) - .map { _, value in value } - let maybeRooms: (info: ResponseInfoType, data: [Room]?)? = maybeRoomResponse - .map { info, data in (info, (data as? HTTP.BatchSubResponse<[Room]>)?.body) } + .map { _, value in value as? HTTP.BatchSubResponse<[Room]> } guard - let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.info, - let capabilities: Capabilities = maybeCapabilities?.data, - let roomsInfo: ResponseInfoType = maybeRooms?.info, - let rooms: [Room] = maybeRooms?.data + let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, + let capabilities: Capabilities = maybeCapabilities?.body, + let roomsInfo: ResponseInfoType = maybeRooms?.responseInfo, + let rooms: [Room] = maybeRooms?.body else { return Fail(error: HTTPError.parsingFailed) .eraseToAnyPublisher() @@ -1263,7 +1258,9 @@ public enum OpenGroupAPI { requests: requestResponseType, using: dependencies ) - .map { $0.values.map { responseInfo, _ in responseInfo } } + .map { _, data -> [ResponseInfoType] in + data.values.compactMap { ($0 as? BatchSubResponseType)?.responseInfo } + } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 845f461a5..ad963705b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -68,36 +68,40 @@ public final class OpenGroupManager { // MARK: - Polling public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { - guard !dependencies.cache.isPolling else { return } + // Run on the 'workQueue' to ensure any 'Atomic' access doesn't block the main thread + // on startup + OpenGroupAPI.workQueue.async { + guard !dependencies.cache.isPolling else { return } - let servers: Set = dependencies.storage - .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.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - } - .defaulting(to: []) - - dependencies.mutableCache.mutate { cache in - cache.isPolling = true - cache.pollers = servers - .reduce(into: [:]) { result, server in - result[server.lowercased()]?.stop() // Should never occur - result[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) + let servers: Set = dependencies.storage + .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.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .distinct() + .asRequest(of: String.self) + .fetchSet(db) } + .defaulting(to: []) - // Note: We loop separately here because when the cache is mocked-out for tests it - // doesn't actually store the value (meaning the pollers won't be started), but if - // we do it in the 'reduce' function, the 'reduce' result will actually store the - // poller value resulting in a bunch of OpenGroup pollers running in a way that can't - // be stopped during unit tests - cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } + dependencies.mutableCache.mutate { cache in + cache.isPolling = true + cache.pollers = servers + .reduce(into: [:]) { result, server in + result[server.lowercased()]?.stop() // Should never occur + result[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) + } + + // Note: We loop separately here because when the cache is mocked-out for tests it + // doesn't actually store the value (meaning the pollers won't be started), but if + // we do it in the 'reduce' function, the 'reduce' result will actually store the + // poller value resulting in a bunch of OpenGroup pollers running in a way that can't + // be stopped during unit tests + cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 9fe36f054..2e4203966 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -7,6 +7,11 @@ import SessionUtilitiesKit extension MessageReceiver { internal static func handleConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + guard !Features.useSharedUtilForUserConfig else { + // TODO: Show warning prompt for X days + return + } + let userPublicKey = getUserHexEncodedPublicKey(db) guard message.sender == userPublicKey else { return } @@ -21,22 +26,41 @@ extension MessageReceiver { .defaulting(to: Date(timeIntervalSince1970: 0)) .timeIntervalSince1970 - // Profile (also force-approve the current user in case the account got into a weird state or - // restored directly from a migration) - try MessageReceiver.updateProfileIfNeeded( + // Handle user profile changes + try ProfileManager.updateProfileIfNeeded( db, publicKey: userPublicKey, name: message.displayName, - profilePictureUrl: message.profilePictureUrl, - profileKey: message.profileKey, + avatarUpdate: { + guard + let profilePictureUrl: String = message.profilePictureUrl, + let profileKey: Data = message.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), sentTimestamp: messageSentTimestamp ) - try Contact(id: userPublicKey) - .with( - isApproved: true, - didApproveMe: true - ) - .save(db) + + // Create a contact for the current user if needed (also force-approve the current user + // in case the account got into a weird state or restored directly from a migration) + let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) + + if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { + try userContact.save(db) + try Contact + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Contact.Columns.isTrusted.set(to: true), + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + } if isInitialSync || messageSentTimestamp > lastConfigTimestamp { if isInitialSync { @@ -53,11 +77,10 @@ extension MessageReceiver { // If the contact is a blinded contact then only add them if they haven't already been // unblinded if SessionId.Prefix(from: sessionId) == .blinded { - let hasUnblindedContact: Bool = (try? BlindedIdLookup + let hasUnblindedContact: Bool = BlindedIdLookup .filter(BlindedIdLookup.Columns.blindedId == sessionId) .filter(BlindedIdLookup.Columns.sessionId != nil) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) if hasUnblindedContact { return @@ -74,13 +97,21 @@ extension MessageReceiver { profile.profilePictureUrl != contactInfo.profilePictureUrl || profile.profileEncryptionKey != contactInfo.profileKey { - try profile - .with( - name: contactInfo.displayName, - profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), - profileEncryptionKey: .updateIf(contactInfo.profileKey) + try profile.save(db) + try Profile + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + Profile.Columns.name.set(to: contactInfo.displayName), + (contactInfo.profilePictureUrl == nil ? nil : + Profile.Columns.profilePictureUrl.set(to: contactInfo.profilePictureUrl) + ), + (contactInfo.profileKey == nil ? nil : + Profile.Columns.profileEncryptionKey.set(to: contactInfo.profileKey) + ) + ].compactMap { $0 } ) - .save(db) } /// We only update these values if the proto actually has values for them (this is to prevent an @@ -94,22 +125,23 @@ extension MessageReceiver { (contactInfo.hasIsBlocked && (contact.isBlocked != contactInfo.isBlocked)) || (contactInfo.hasDidApproveMe && (contact.didApproveMe != contactInfo.didApproveMe)) { - try contact - .with( - isApproved: (contactInfo.hasIsApproved && contactInfo.isApproved ? - true : - .existing - ), - isBlocked: (contactInfo.hasIsBlocked ? - .update(contactInfo.isBlocked) : - .existing - ), - didApproveMe: (contactInfo.hasDidApproveMe && contactInfo.didApproveMe ? - true : - .existing - ) + try contact.save(db) + try Contact + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (!contactInfo.hasIsApproved || !contactInfo.isApproved ? nil : + Contact.Columns.isApproved.set(to: true) + ), + (!contactInfo.hasIsBlocked ? nil : + Contact.Columns.isBlocked.set(to: contactInfo.isBlocked) + ), + (!contactInfo.hasDidApproveMe || !contactInfo.didApproveMe ? nil : + Contact.Columns.didApproveMe.set(to: contactInfo.didApproveMe) + ) + ].compactMap { $0 } ) - .save(db) } // If the contact is blocked diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 179d6a5b3..66320c92a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -23,12 +23,22 @@ extension MessageReceiver { if let profile = message.profile { let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) - try MessageReceiver.updateProfileIfNeeded( + try ProfileManager.updateProfileIfNeeded( db, publicKey: senderId, name: profile.displayName, - profilePictureUrl: profile.profilePictureUrl, - profileKey: profile.profileKey, + avatarUpdate: { + guard + let profilePictureUrl: String = profile.profilePictureUrl, + let profileKey: Data = profile.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), sentTimestamp: messageSentTimestamp ) } @@ -88,8 +98,7 @@ extension MessageReceiver { try updateContactApprovalStatusIfNeeded( db, senderSessionId: senderId, - threadId: nil, - forceConfigSync: blindedContactIds.isEmpty // Sync here if there were no blinded contacts + threadId: nil ) // If there were blinded contacts which have now been resolved to this contact then we should remove @@ -103,8 +112,7 @@ extension MessageReceiver { try updateContactApprovalStatusIfNeeded( db, senderSessionId: userPublicKey, - threadId: unblindedThread.id, - forceConfigSync: true + threadId: unblindedThread.id ) } @@ -128,8 +136,7 @@ extension MessageReceiver { internal static func updateContactApprovalStatusIfNeeded( _ db: Database, senderSessionId: String, - threadId: String?, - forceConfigSync: Bool + threadId: String? ) throws { let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -149,9 +156,10 @@ extension MessageReceiver { guard !contact.isApproved else { return } - _ = try? contact - .with(isApproved: true) - .saved(db) + try? contact.save(db) + _ = try? Contact + .filter(id: threadId) + .updateAllAndConfig(db, Contact.Columns.isApproved.set(to: true)) } else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to @@ -160,14 +168,10 @@ extension MessageReceiver { guard !contact.didApproveMe else { return } - _ = try? contact - .with(didApproveMe: true) - .saved(db) + try? contact.save(db) + _ = try? Contact + .filter(id: senderSessionId) + .updateAllAndConfig(db, Contact.Columns.didApproveMe.set(to: true)) } - - // 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).sinkUntilComplete() } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 8e6963210..31045cd8d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -25,12 +25,22 @@ extension MessageReceiver { // Update profile if needed (want to do this regardless of whether the message exists or // not to ensure the profile info gets sync between a users devices at every chance) if let profile = message.profile { - try MessageReceiver.updateProfileIfNeeded( + try ProfileManager.updateProfileIfNeeded( db, publicKey: sender, name: profile.displayName, - profilePictureUrl: profile.profilePictureUrl, - profileKey: profile.profileKey, + avatarUpdate: { + guard + let profilePictureUrl: String = profile.profilePictureUrl, + let profileKey: Data = profile.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), sentTimestamp: messageSentTimestamp ) } @@ -272,8 +282,7 @@ extension MessageReceiver { try MessageReceiver.updateContactApprovalStatusIfNeeded( db, senderSessionId: sender, - threadId: thread.id, - forceConfigSync: false + threadId: thread.id ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index e3fa2ec73..5f7c64244 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -103,7 +103,7 @@ extension MessageSender { // the 'ClosedGroup' object we created sentTimestampMs: UInt64(floor(formationTimestamp * 1000)) ), - to: .contact(publicKey: memberId, namespace: .default), + to: .contact(publicKey: memberId), interactionId: nil ) } @@ -197,7 +197,8 @@ extension MessageSender { ClosedGroupControlMessage.KeyPairWrapper( publicKey: memberPublicKey, encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( - plaintext, + db, + plaintext: plaintext, for: memberPublicKey ) ) @@ -645,7 +646,11 @@ extension MessageSender { let plaintext = try proto.serializedData() let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: publicKey, variant: .contact) - let ciphertext = try MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) + let ciphertext = try MessageSender.encryptWithSessionProtocol( + db, + plaintext: plaintext, + for: publicKey + ) SNLog("Sending latest encryption key pair to: \(publicKey).") try MessageSender.send( diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 357a9b915..d117f399e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -305,82 +305,4 @@ public enum MessageReceiver { return (contactId, .contact) } - - internal static func updateProfileIfNeeded( - _ db: Database, - publicKey: String, - name: String?, - profilePictureUrl: String?, - profileKey: Data?, - sentTimestamp: TimeInterval, - dependencies: Dependencies = Dependencies() - ) throws { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) - let profile: Profile = Profile.fetchOrCreate(id: publicKey) - var updatedProfile: Profile = profile - - // Name - if let name = name, name != profile.name { - let shouldUpdate: Bool - if isCurrentUser { - shouldUpdate = given(UserDefaults.standard[.lastDisplayNameUpdate]) { - sentTimestamp > $0.timeIntervalSince1970 - } - .defaulting(to: true) - } - else { - shouldUpdate = true - } - - if shouldUpdate { - if isCurrentUser { - UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) - } - - updatedProfile = updatedProfile.with(name: name) - } - } - - // Profile picture & profile key - if - let profileKey: Data = profileKey, - let profilePictureUrl: String = profilePictureUrl, - profileKey.count == ProfileManager.avatarAES256KeyByteLength, - profileKey != profile.profileEncryptionKey - { - let shouldUpdate: Bool - if isCurrentUser { - shouldUpdate = given(UserDefaults.standard[.lastProfilePictureUpdate]) { - sentTimestamp > $0.timeIntervalSince1970 - } - .defaulting(to: true) - } - else { - shouldUpdate = true - } - - if shouldUpdate { - if isCurrentUser { - UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp) - } - - updatedProfile = updatedProfile.with( - profilePictureUrl: .update(profilePictureUrl), - profileEncryptionKey: .update(profileKey) - ) - } - } - - // Persist any changes - if updatedProfile != profile { - try updatedProfile.save(db) - } - - // Download the profile picture if needed - if updatedProfile.profilePictureUrl != profile.profilePictureUrl || updatedProfile.profileEncryptionKey != profile.profileEncryptionKey { - db.afterNextTransaction { _ in - ProfileManager.downloadAvatar(for: updatedProfile) - } - } - } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 0aa47a509..b3db30c7e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -85,8 +85,8 @@ extension MessageSender { let threadId: String = { switch destination { - case .contact(let publicKey, _): return publicKey - case .closedGroup(let groupPublicKey, _): return groupPublicKey + 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) @@ -152,87 +152,4 @@ extension MessageSender { } .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 -> AnyPublisher { - // 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 Fail(error: StorageError.generic) - .eraseToAnyPublisher() - } - - let publicKey: String = getUserHexEncodedPublicKey(db) - let legacyDestination: Message.Destination = Message.Destination.contact( - publicKey: publicKey, - namespace: .default - ) - let legacyConfigurationMessage = try ConfigurationMessage.getCurrent(db) - let userConfigMessageChanges: [SharedConfigMessage] = SessionUtil.getChanges( - ed25519SecretKey: ed25519SecretKey - ) - let destination: Message.Destination = Message.Destination.contact( - publicKey: publicKey, - namespace: .userProfileConfig - ) - - guard forceSyncNow else { - JobRunner.add( - db, - job: Job( - variant: .messageSend, - threadId: publicKey, - details: MessageSendJob.Details( - destination: legacyDestination, - message: legacyConfigurationMessage - ) - ) - ) - - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - let sendData: PreparedSendData = try MessageSender.preparedSendData( - db, - message: legacyConfigurationMessage, - to: legacyDestination, - interactionId: nil - ) - - let userConfigSendData: [PreparedSendData] = try userConfigMessageChanges - .map { message in - try MessageSender.preparedSendData( - db, - message: message, - to: destination, - interactionId: nil - ) - } - - /// We want to avoid blocking the db write thread so we dispatch the API call to a different thread - return Just(()) - .setFailureType(to: Error.self) - .receive(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { _ -> AnyPublisher in - Publishers - .MergeMany( - ([sendData] + userConfigSendData) - .map { MessageSender.sendImmediate(preparedSendData: $0) } - ) - .collect() - .map { _ in () } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 99f4e6765..0517705ab 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -1,16 +1,18 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import SessionUtilitiesKit extension MessageSender { internal static func encryptWithSessionProtocol( - _ plaintext: Data, + _ db: Database, + plaintext: Data, for recipientHexEncodedX25519PublicKey: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> Data { - guard let userEd25519KeyPair: Box.KeyPair = dependencies.storage.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + guard let userEd25519KeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } @@ -30,13 +32,16 @@ extension MessageSender { } internal static func encryptWithSessionBlindingProtocol( - _ plaintext: Data, + _ db: Database, + plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> Data { - guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw MessageSenderError.signingFailed } - guard let userEd25519KeyPair: Box.KeyPair = dependencies.storage.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { + throw MessageSenderError.signingFailed + } + guard let userEd25519KeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f60609b0d..0ccb31ce3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -206,8 +206,8 @@ public final class MessageSender { message.sender = userPublicKey message.recipient = { switch destination { - case .contact(let publicKey, _): return publicKey - case .closedGroup(let groupPublicKey, _): return groupPublicKey + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey case .openGroup, .openGroupInbox: preconditionFailure() } }() @@ -283,16 +283,17 @@ public final class MessageSender { let ciphertext: Data do { switch destination { - case .contact(let publicKey, _): - ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) + case .contact(let publicKey): + ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey) - case .closedGroup(let groupPublicKey, _): + case .closedGroup(let groupPublicKey): guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { throw MessageSenderError.noKeyPair } ciphertext = try encryptWithSessionProtocol( - plaintext, + db, + plaintext: plaintext, for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString ) @@ -319,7 +320,7 @@ public final class MessageSender { kind = .sessionMessage senderPublicKey = "" - case .closedGroup(let groupPublicKey, _): + case .closedGroup(let groupPublicKey): kind = .closedGroupMessage senderPublicKey = groupPublicKey @@ -553,7 +554,8 @@ public final class MessageSender { do { ciphertext = try encryptWithSessionBlindingProtocol( - plaintext, + db, + plaintext: plaintext, for: recipientBlindedPublicKey, openGroupPublicKey: openGroupPublicKey, using: dependencies @@ -636,107 +638,86 @@ public final class MessageSender { let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]) .defaulting(to: false) - var isSuccess = false - var errorCount = 0 return SnodeAPI .sendMessage( snodeMessage, - in: destination.namespace + in: { + switch destination { + case .closedGroup: return .legacyClosedGroup + default: return .`default` + } + }() ) .subscribe(on: DispatchQueue.global(qos: .default)) - .flatMap { result, totalCount -> AnyPublisher in - switch result { - case .success(let response): - // Don't emit if we've already succeeded - guard !isSuccess else { - return Just(false) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - isSuccess = true - - let updatedMessage: Message = message - updatedMessage.serverHash = response.1.hash - - let job: Job? = Job( - variant: .notifyPushServer, - behaviour: .runOnce, - details: NotifyPushServerJob.Details(message: snodeMessage) - ) - let shouldNotify: Bool = { - switch updatedMessage { - case is VisibleMessage, is UnsendRequest: return !isSyncMessage - case let callMessage as CallMessage: - switch callMessage.kind { - case .preOffer: return true - default: return false - } + .flatMap { response -> AnyPublisher in + let updatedMessage: Message = message + updatedMessage.serverHash = response.1.hash + let job: Job? = Job( + variant: .notifyPushServer, + behaviour: .runOnce, + details: NotifyPushServerJob.Details(message: snodeMessage) + ) + let shouldNotify: Bool = { + switch updatedMessage { + case is VisibleMessage, is UnsendRequest: return !isSyncMessage + case let callMessage as CallMessage: + switch callMessage.kind { + case .preOffer: return true default: return false } - }() - return dependencies.storage - .writePublisher { db -> Void in - try MessageSender.handleSuccessfulMessageSend( - db, - message: updatedMessage, - to: destination, - interactionId: data.interactionId, - isSyncMessage: isSyncMessage, - using: dependencies - ) + default: return false + } + }() - guard shouldNotify && isMainAppActive else { return () } + return dependencies.storage + .writePublisher { db -> Void in + try MessageSender.handleSuccessfulMessageSend( + db, + message: updatedMessage, + to: destination, + interactionId: data.interactionId, + isSyncMessage: isSyncMessage, + using: dependencies + ) - JobRunner.add(db, job: job) - return () - } - .flatMap { _ -> AnyPublisher in - guard shouldNotify && !isMainAppActive else { - return Just(true) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - guard let job: Job = job else { - return Just(true) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } + guard shouldNotify else { return () } - return Future { resolver in - NotifyPushServerJob.run( - job, - queue: DispatchQueue.global(qos: .default), - success: { _, _ in resolver(Result.success(true)) }, - failure: { _, _, _ in - // Always fulfill because the notify PN server job isn't critical. - resolver(Result.success(true)) - }, - deferred: { _ in - // Always fulfill because the notify PN server job isn't critical. - resolver(Result.success(true)) - } - ) - } + JobRunner.add(db, job: job) + return () + } + .flatMap { _ -> AnyPublisher in + guard shouldNotify && !isMainAppActive else { + return Just(true) + .setFailureType(to: Error.self) .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - - case .failure(let error): - errorCount += 1 - - // Only process the error if all promises failed - guard errorCount == totalCount else { - return Just(false) + } + guard let job: Job = job else { + return Just(true) .setFailureType(to: Error.self) .eraseToAnyPublisher() } - return Fail(error: error) - .eraseToAnyPublisher() - } + return Future { resolver in + NotifyPushServerJob.run( + job, + queue: DispatchQueue.global(qos: .default), + success: { _, _ in resolver(Result.success(true)) }, + failure: { _, _, _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(true)) + }, + deferred: { _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(true)) + } + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } .filter { $0 } .handleEvents( @@ -960,8 +941,8 @@ public final class MessageSender { try? ControlMessageProcessRecord( threadId: { switch destination { - case .contact(let publicKey, _): return publicKey - case .closedGroup(let groupPublicKey, _): return groupPublicKey + 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) @@ -977,7 +958,7 @@ public final class MessageSender { // • the destination was a contact // • we didn't sync it already let userPublicKey = getUserHexEncodedPublicKey(db) - if case .contact(let publicKey, let namespace) = destination, !isSyncMessage { + if case .contact(let publicKey) = destination, !isSyncMessage { if let message = message as? VisibleMessage { message.syncTarget = publicKey } if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } @@ -986,7 +967,7 @@ public final class MessageSender { data: try prepareSendToSnodeDestination( db, message: message, - to: .contact(publicKey: userPublicKey, namespace: namespace), + to: .contact(publicKey: userPublicKey), interactionId: interactionId, userPublicKey: userPublicKey, messageSendTimestamp: Int64(floor(Date().timeIntervalSince1970 * 1000)), diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index 5c6ffe962..d5c5b48fa 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -4,6 +4,7 @@ import Foundation public extension Notification.Name { + // FIXME: Remove once `useSharedUtilForUserConfig` is permanent static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") static let missedCall = Notification.Name("missedCall") } @@ -14,5 +15,6 @@ public extension Notification.Key { @objc public extension NSNotification { + // FIXME: Remove once `useSharedUtilForUserConfig` is permanent @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index ebaaba9e1..ed88e7edc 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -8,7 +8,7 @@ import SessionSnodeKit import SessionUtilitiesKit public final class CurrentUserPoller: Poller { - public static var namespaces: [SnodeAPI.Namespace] = [.default, .userProfileConfig] + public static var namespaces: [SnodeAPI.Namespace] = [.default, .configUserProfile, .configContacts] private var targetSnode: Atomic = Atomic(nil) private var usedSnodes: Atomic> = Atomic([]) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 317c8c99d..557642d1d 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit extension OpenGroupAPI { public final class Poller { - typealias PollResponse = [OpenGroupAPI.Endpoint: (info: ResponseInfoType, data: Codable?)] + typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Codable]) private let server: String private var timer: Timer? = nil @@ -279,11 +279,11 @@ extension OpenGroupAPI { using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() ) { let server: String = self.server - let validResponses: PollResponse = response - .filter { endpoint, endpointResponse in + let validResponses: [OpenGroupAPI.Endpoint: Codable] = response.data + .filter { endpoint, data in switch endpoint { case .capabilities: - guard (endpointResponse.data as? HTTP.BatchSubResponse)?.body != nil else { + guard (data as? HTTP.BatchSubResponse)?.body != nil else { SNLog("Open group polling failed due to invalid capability data.") return false } @@ -291,8 +291,8 @@ extension OpenGroupAPI { return true case .roomPollInfo(let roomToken, _): - guard (endpointResponse.data as? HTTP.BatchSubResponse)?.body != nil else { - switch (endpointResponse.data as? HTTP.BatchSubResponse)?.code { + guard (data as? HTTP.BatchSubResponse)?.body != nil else { + switch (data as? HTTP.BatchSubResponse)?.code { case 404: SNLog("Open group polling failed to retrieve info for unknown room '\(roomToken)'.") default: SNLog("Open group polling failed due to invalid room info data.") } @@ -303,10 +303,10 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard - let responseData: HTTP.BatchSubResponse<[Failable]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable]>, + let responseData: HTTP.BatchSubResponse<[Failable]> = data as? HTTP.BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { - switch (endpointResponse.data as? HTTP.BatchSubResponse<[Failable]>)?.code { + switch (data as? HTTP.BatchSubResponse<[Failable]>)?.code { case 404: SNLog("Open group polling failed to retrieve messages for unknown room '\(roomToken)'.") default: SNLog("Open group polling failed due to invalid messages data.") } @@ -325,7 +325,7 @@ extension OpenGroupAPI { case .inbox, .inboxSince, .outbox, .outboxSince: guard - let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>, + let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = data as? HTTP.BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { SNLog("Open group polling failed due to invalid inbox/outbox data.") @@ -378,12 +378,12 @@ extension OpenGroupAPI { return (capabilities, groups) } - let changedResponses: PollResponse = validResponses - .filter { endpoint, endpointResponse in + let changedResponses: [OpenGroupAPI.Endpoint: Codable] = validResponses + .filter { endpoint, data in switch endpoint { case .capabilities: guard - let responseData: HTTP.BatchSubResponse = endpointResponse.data as? HTTP.BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: Capabilities = responseData.body else { return false } @@ -391,7 +391,7 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard - let responseData: HTTP.BatchSubResponse = endpointResponse.data as? HTTP.BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { return false } guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else { @@ -424,11 +424,11 @@ extension OpenGroupAPI { .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) } - try changedResponses.forEach { endpoint, endpointResponse in + try changedResponses.forEach { endpoint, data in switch endpoint { case .capabilities: guard - let responseData: HTTP.BatchSubResponse = endpointResponse.data as? HTTP.BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: Capabilities = responseData.body else { return } @@ -440,7 +440,7 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard - let responseData: HTTP.BatchSubResponse = endpointResponse.data as? HTTP.BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { return } @@ -455,7 +455,7 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard - let responseData: HTTP.BatchSubResponse<[Failable]> = endpointResponse.data as? HTTP.BatchSubResponse<[Failable]>, + let responseData: HTTP.BatchSubResponse<[Failable]> = data as? HTTP.BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { return } @@ -469,7 +469,7 @@ extension OpenGroupAPI { case .inbox, .inboxSince, .outbox, .outboxSince: guard - let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? HTTP.BatchSubResponse<[DirectMessage]?>, + let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = data as? HTTP.BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 8a5ab36a4..5bca3274e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -66,13 +66,17 @@ public class Poller { // MARK: - Private API internal func startIfNeeded(for publicKey: String) { - guard isPolling.wrappedValue[publicKey] != 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[publicKey] = true } - setUpPolling(for: publicKey) + // Run on the 'pollerQueue' to ensure any 'Atomic' access doesn't block the main thread + // on startup + Threading.pollerQueue.async { [weak self] in + guard self?.isPolling.wrappedValue[publicKey] != 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 + self?.isPolling.mutate { $0[publicKey] = true } + self?.setUpPolling(for: publicKey) + } } /// We want to initially trigger a poll against the target service node and then run the recursive polling, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 81c452039..99664cdeb 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -146,8 +146,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // MARK: - Mutation public func with( - attachments: Updatable<[Attachment]> = .existing, - reactionInfo: Updatable<[ReactionInfo]> = .existing + attachments: [Attachment]? = nil, + reactionInfo: [ReactionInfo]? = nil ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, @@ -845,11 +845,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with( - attachments: .update( - attachments - .sorted() - .map { $0.attachment } - ) + attachments: attachments + .sorted() + .map { $0.attachment } ) ) } @@ -927,7 +925,7 @@ public extension MessageViewModel.ReactionInfo { else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: .update(reactionInfo.sorted())) + dataToUpdate.with(reactionInfo: reactionInfo.sorted()) ) pagedRowIdsWithNoReactions.remove(interactionRowId) } diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index a4c671ccf..9cf23b523 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -8,6 +8,28 @@ import SignalCoreKit import SessionUtilitiesKit public struct ProfileManager { + public enum AvatarUpdate { + case none + case remove + case uploadImage(UIImage) + case uploadFilePath(String) + case updateTo(url: String, key: Data, fileName: String?) + + var image: UIImage? { + switch self { + case .uploadImage(let image): return image + default: return nil + } + } + + var filePath: String? { + switch self { + case .uploadFilePath(let filePath): return filePath + default: return nil + } + } + } + // The max bytes for a user's profile name, encoded in UTF8. // Before encrypting and submitting we NULL pad the name data to this length. private static let nameDataLength: UInt = 64 @@ -263,77 +285,85 @@ public struct ProfileManager { public static func updateLocal( queue: DispatchQueue, profileName: String, - image: UIImage?, - imageFilePath: String?, - success: ((Database, Profile) throws -> ())? = nil, + avatarUpdate: AvatarUpdate = .none, + success: ((Database) throws -> ())? = nil, failure: ((ProfileManagerError) -> ())? = nil ) { - prepareAndUploadAvatarImage( - queue: queue, - image: image, - imageFilePath: imageFilePath, - success: { fileInfo, newProfileKey in - // If we have no download url the we are removing the profile image - guard let (downloadUrl, fileName): (String, String) = fileInfo else { - Storage.shared.writeAsync { db in - let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + let userPublicKey: String = getUserHexEncodedPublicKey() + let isRemovingAvatar: Bool = { + switch avatarUpdate { + case .remove: return true + default: return false + } + }() + + switch avatarUpdate { + case .none, .remove, .updateTo: + Storage.shared.writeAsync { db in + if isRemovingAvatar { + let existingProfileUrl: String? = try Profile + .filter(id: userPublicKey) + .select(.profilePictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + let existingProfileFileName: String? = try Profile + .filter(id: userPublicKey) + .select(.profilePictureFileName) + .asRequest(of: String.self) + .fetchOne(db) - OWSLogger.verbose(existingProfile.profilePictureUrl != nil ? + // Remove any cached avatar image value + if let fileName: String = existingProfileFileName { + profileAvatarCache.mutate { $0[fileName] = nil } + } + + OWSLogger.verbose(existingProfileUrl != nil ? "Updating local profile on service with cleared avatar." : "Updating local profile on service with no avatar." ) - - let updatedProfile: Profile = try existingProfile - .with( - name: profileName, - profilePictureUrl: nil, - profilePictureFileName: nil, - profileEncryptionKey: (existingProfile.profilePictureUrl != nil ? - .update(newProfileKey) : - .existing - ) - ) - .saved(db) - - try SessionUtil.update( - profile: updatedProfile, - in: .global(variant: .userProfile) - ) - - SNLog("Successfully updated service with profile.") - try success?(db, updatedProfile) } - return - } - - // Update user defaults - UserDefaults.standard[.lastProfilePictureUpload] = Date() - - // Update the profile - 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) + + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: profileName, + avatarUpdate: avatarUpdate, + sentTimestamp: Date().timeIntervalSince1970 + ) SNLog("Successfully updated service with profile.") - try success?(db, profile) + try success?(db) } - }, - failure: failure - ) + + case .uploadFilePath, .uploadImage: + prepareAndUploadAvatarImage( + queue: queue, + image: avatarUpdate.image, + imageFilePath: avatarUpdate.filePath, + success: { downloadUrl, fileName, newProfileKey in + Storage.shared.writeAsync { db in + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: profileName, + avatarUpdate: .updateTo(url: downloadUrl, key: newProfileKey, fileName: fileName), + sentTimestamp: Date().timeIntervalSince1970 + ) + + SNLog("Successfully updated service with profile.") + try success?(db) + } + }, + failure: failure + ) + } } - public static func prepareAndUploadAvatarImage( + private static func prepareAndUploadAvatarImage( queue: DispatchQueue, image: UIImage?, imageFilePath: String?, - success: @escaping ((downloadUrl: String, fileName: String)?, Data) -> (), + success: @escaping ((downloadUrl: String, fileName: String, profileKey: Data)) -> (), failure: ((ProfileManagerError) -> ())? = nil ) { queue.async { @@ -348,7 +378,9 @@ public struct ProfileManager { avatarImageData = try { guard var image: UIImage = image else { - guard let imageFilePath: String = imageFilePath else { return nil } + guard let imageFilePath: String = imageFilePath else { + throw ProfileManagerError.invalidCall + } let data: Data = try Data(contentsOf: URL(fileURLWithPath: imageFilePath)) @@ -397,20 +429,8 @@ public struct ProfileManager { // If we have no image then we should succeed (database changes happen in the callback) guard let data: Data = avatarImageData else { - // Remove any cached avatar image value - let maybeExistingFileName: String? = Storage.shared - .read { db in - try Profile - .select(.profilePictureFileName) - .asRequest(of: String.self) - .fetchOne(db) - } - - if let fileName: String = maybeExistingFileName { - profileAvatarCache.mutate { $0[fileName] = nil } - } - - return success(nil, newProfileKey) + failure?(ProfileManagerError.invalidCall) + return } // If we have a new avatar image, we must first: @@ -469,9 +489,124 @@ public struct ProfileManager { profileAvatarCache.mutate { $0[fileName] = data } SNLog("Successfully uploaded avatar image.") - success((downloadUrl, fileName), newProfileKey) + success((downloadUrl, fileName, newProfileKey)) } ) } } + + public static func updateProfileIfNeeded( + _ db: Database, + publicKey: String, + name: String?, + avatarUpdate: AvatarUpdate, + sentTimestamp: TimeInterval, + calledFromConfigHandling: Bool = false, + dependencies: Dependencies = Dependencies() + ) throws { + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) + let profile: Profile = Profile.fetchOrCreate(id: publicKey) + var profileChanges: [ColumnAssignment] = [] + + // Name + if let name: String = name, !name.isEmpty, name != profile.name { + let shouldUpdate: Bool + if isCurrentUser { + shouldUpdate = given(UserDefaults.standard[.lastDisplayNameUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + } + else { + shouldUpdate = true + } + + if shouldUpdate { + if isCurrentUser { + UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) + } + + profileChanges.append(Profile.Columns.name.set(to: name)) + } + } + + // Profile picture & profile key + var avatarNeedsDownload: Bool = false + let shouldUpdateAvatar: Bool = { + guard isCurrentUser else { return true } + + return given(UserDefaults.standard[.lastProfilePictureUpdate]) { + sentTimestamp > $0.timeIntervalSince1970 + } + .defaulting(to: true) + }() + + if shouldUpdateAvatar { + switch avatarUpdate { + case .none: break + case .uploadImage, .uploadFilePath: preconditionFailure("Invalid options for this function") + + case .remove: + if isCurrentUser { + UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp) + } + + profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil)) + profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil)) + + // Profile filename (this isn't synchronized between devices so can be immediately saved) + _ = try? Profile + .filter(id: publicKey) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil)) + + case .updateTo(let url, let key, let fileName): + if + ( + url != profile.profilePictureUrl || + key != profile.profileEncryptionKey + ) && + key.count == ProfileManager.avatarAES256KeyByteLength && + key != profile.profileEncryptionKey + { + profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url)) + profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key)) + avatarNeedsDownload = true + } + + // Profile filename (this isn't synchronized between devices so can be immediately saved) + if let fileName: String = fileName { + _ = try? Profile + .filter(id: publicKey) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) + } + } + } + + // Persist any changes + if !profileChanges.isEmpty { + try profile.save(db) + + if calledFromConfigHandling { + try Profile + .filter(id: publicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + profileChanges + ) + } + else { + try Profile + .filter(id: publicKey) + .updateAllAndConfig(db, profileChanges) + } + } + + // Download the profile picture if needed + guard avatarNeedsDownload else { return } + + db.afterNextTransaction { db in + // Need to refetch to ensure the db changes have occurred + ProfileManager.downloadAvatar(for: Profile.fetchOrCreate(id: publicKey)) + } + } } diff --git a/SessionMessagingKit/Utilities/ProfileManagerError.swift b/SessionMessagingKit/Utilities/ProfileManagerError.swift index 1be60fbad..a522e492e 100644 --- a/SessionMessagingKit/Utilities/ProfileManagerError.swift +++ b/SessionMessagingKit/Utilities/ProfileManagerError.swift @@ -8,6 +8,7 @@ public enum ProfileManagerError: LocalizedError { case avatarEncryptionFailed case avatarUploadFailed case avatarUploadMaxFileSizeExceeded + case invalidCall var localizedDescription: String { switch self { @@ -16,6 +17,7 @@ public enum ProfileManagerError: LocalizedError { case .avatarEncryptionFailed: return "Avatar encryption failed." case .avatarUploadFailed: return "Avatar upload failed." case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded." + case .invalidCall: return "Attempted to remove avatar using the wrong method." } } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index c83962cec..7dd880554 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -143,7 +143,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension self.handleSuccessForIncomingCall(db, for: callMessage) case let sharedConfigMessage as SharedConfigMessage: - try SessionUtil.handleConfigMessages(db, messages: [sharedConfigMessage]) + try SessionUtil.handleConfigMessages( + db, + messages: [sharedConfigMessage], + publicKey: (processedMessage.threadId ?? "") + ) default: break } @@ -214,9 +218,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).sinkUntilComplete() - } + ConfigurationSyncJob.enqueue() } checkIsAppReady() diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 5edc9cedc..c5eb6ce7e 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -92,9 +92,7 @@ final class ShareNavController: 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).sinkUntilComplete() - } + ConfigurationSyncJob.enqueue() } checkIsAppReady() diff --git a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift index 71156a4c7..45a0f1d5c 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -18,13 +18,6 @@ public enum GetSnodePoolJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { - // If the user doesn't exist then don't do anything (when the user registers we run this - // job directly) - guard Identity.userExists() else { - deferred(job) - return - } - // If we already have cached Snodes then we still want to trigger the 'SnodeAPI.getSnodePool' // but we want to succeed this job immediately (since it's marked as blocking), this allows us // to block if we have no Snode pool and prevent other jobs from failing but avoids having to @@ -35,7 +28,10 @@ public enum GetSnodePoolJob: JobExecutor { return } + // If we don't have the snode pool cached then we should also try to build the path (this will + // speed up the onboarding process for new users because it can run before the user is created) SnodeAPI.getSnodePool() + .flatMap { _ in OnionRequestAPI.getPath(excluding: nil) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( diff --git a/SessionSnodeKit/Models/SendMessageRequest.swift b/SessionSnodeKit/Models/SendMessageRequest.swift index b64748ddc..91831f155 100644 --- a/SessionSnodeKit/Models/SendMessageRequest.swift +++ b/SessionSnodeKit/Models/SendMessageRequest.swift @@ -6,7 +6,7 @@ extension SnodeAPI { public class SendMessageRequest: SnodeAuthenticatedRequestBody { enum CodingKeys: String, CodingKey { case namespace - case signatureTimestamp = "sig_timestamp" + case signatureTimestamp = "timestamp"//"sig_timestamp" // TODO: Add this back once the snodes are fixed!! } let message: SnodeMessage diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index e078abe1e..d98c3212e 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -209,7 +209,7 @@ 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> { + internal 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 diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 3c88ada34..694213d93 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -7,6 +7,8 @@ import GRDB import SessionUtilitiesKit public final class SnodeAPI { + public typealias TargetedMessage = (message: SnodeMessage, namespace: Namespace) + internal static let sodium: Atomic = Atomic(Sodium()) private static var hasLoadedSnodePool: Atomic = Atomic(false) @@ -47,7 +49,6 @@ public final class SnodeAPI { ] }() private static let snodeFailureThreshold: Int = 3 - private static let targetSwarmSnodeCount: Int = 2 private static let minSnodePoolCount: Int = 12 private static func offsetTimestampMsNow() -> UInt64 { @@ -269,13 +270,6 @@ public final class SnodeAPI { .eraseToAnyPublisher() } - public static func getTargetSnodes(for publicKey: String) -> AnyPublisher<[Snode], Error> { - // shuffled() uses the system's default random generator, which is cryptographically secure - return getSwarm(for: publicKey) - .map { Array($0.shuffled().prefix(targetSwarmSnodeCount)) } - .eraseToAnyPublisher() - } - public static func getSwarm( for publicKey: String, using dependencies: SSKDependencies = SSKDependencies() @@ -422,19 +416,21 @@ public final class SnodeAPI { ) .decoded(as: responseTypes, using: dependencies) .map { batchResponse -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] in - zip(namespaces, batchResponse) + zip(namespaces, batchResponse.responses) .reduce(into: [:]) { result, next in - guard let messageResponse: GetMessagesResponse = (next.1.1 as? HTTP.BatchSubResponse)?.body else { + guard + let subResponse: HTTP.BatchSubResponse = (next.1 as? HTTP.BatchSubResponse), + let messageResponse: GetMessagesResponse = subResponse.body + else { return } let namespace: SnodeAPI.Namespace = next.0 - let requestInfo: ResponseInfoType = next.1.0 - + result[namespace] = ( - requestInfo, - ( - messageResponse.messages + info: subResponse.responseInfo, + data: ( + messages: messageResponse.messages .compactMap { rawMessage -> SnodeReceivedMessage? in SnodeReceivedMessage( snode: snode, @@ -443,7 +439,7 @@ public final class SnodeAPI { rawMessage: rawMessage ) }, - namespaceLastHash[namespace] + lastHash: namespaceLastHash[namespace] ) ) } @@ -453,13 +449,13 @@ public final class SnodeAPI { .eraseToAnyPublisher() } - // MARK: Store + // MARK: - Store public static func sendMessage( _ message: SnodeMessage, in namespace: Namespace, using dependencies: SSKDependencies = SSKDependencies() - ) -> AnyPublisher<(Result<(ResponseInfoType, SendMessagesResponse), Error>, Int), Error> { + ) -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> { let publicKey: String = (Features.useTestnet ? message.recipient.removingIdPrefixIfNeeded() : message.recipient @@ -511,27 +507,125 @@ public final class SnodeAPI { .eraseToAnyPublisher() } - return getTargetSnodes(for: publicKey) + return getSwarm(for: publicKey) .subscribe(on: Threading.workQueue) - .flatMap { targetSnodes -> AnyPublisher<(Result<(ResponseInfoType, SendMessagesResponse), Error>, Int), Error> in - Publishers - .MergeMany( - targetSnodes - .map { targetSnode -> AnyPublisher, Never> in - return sendMessage(to: targetSnode) - .retry(maxRetryCount) - .eraseToAnyPublisher() - .asResult() - } - ) - .map { result in (result, targetSnodes.count) } - .setFailureType(to: Error.self) + .flatMap { swarm -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> in + guard let snode: Snode = swarm.randomElement() else { + return Fail(error: SnodeAPIError.generic) + .eraseToAnyPublisher() + } + + return sendMessage(to: snode) + .retry(maxRetryCount) .eraseToAnyPublisher() } + .retry(maxRetryCount) .eraseToAnyPublisher() } - // MARK: Edit + public static func sendConfigMessages( + _ targetedMessages: [TargetedMessage], + oldHashes: [String], + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + guard + !targetedMessages.isEmpty, + let recipient: String = targetedMessages.first?.message.recipient + else { + return Fail(error: SnodeAPIError.generic) + .eraseToAnyPublisher() + } + // TODO: Need to get either the closed group subKey or the userEd25519 key for auth + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let publicKey: String = (Features.useTestnet ? + recipient.removingIdPrefixIfNeeded() : + recipient + ) + var requests: [SnodeAPI.BatchRequest.Info] = targetedMessages + .map { message, namespace in + // Check if this namespace requires authentication + guard namespace.requiresReadAuthentication else { + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .sendMessage, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ) + ), + responseType: SendMessagesResponse.self + ) + } + + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .sendMessage, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, // TODO: Need to get this + timestampMs: SnodeAPI.offsetTimestampMsNow(), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: SendMessagesResponse.self + ) + } + + // If we had any previous config messages then we should delete them + if !oldHashes.isEmpty { + requests.append( + BatchRequest.Info( + request: SnodeRequest( + endpoint: .deleteMessages, + body: DeleteMessagesRequest( + messageHashes: oldHashes, + requireSuccessfulDeletion: false, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: DeleteMessagesResponse.self + ) + ) + } + + let responseTypes = requests.map { $0.responseType } + + return getSwarm(for: publicKey) + .subscribe(on: Threading.workQueue) + .flatMap { swarm -> AnyPublisher in + guard let snode: Snode = swarm.randomElement() else { + return Fail(error: SnodeAPIError.generic) + .eraseToAnyPublisher() + } + + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .sequence, + body: BatchRequest(requests: requests) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .eraseToAnyPublisher() + .decoded(as: responseTypes, using: dependencies) + .eraseToAnyPublisher() + } + .retry(maxRetryCount) + .eraseToAnyPublisher() + } + + // MARK: - Edit public static func updateExpiry( publicKey: String, diff --git a/SessionSnodeKit/Types/SnodeAPINamespace.swift b/SessionSnodeKit/Types/SnodeAPINamespace.swift index da038aeca..17fc93056 100644 --- a/SessionSnodeKit/Types/SnodeAPINamespace.swift +++ b/SessionSnodeKit/Types/SnodeAPINamespace.swift @@ -3,10 +3,12 @@ import Foundation public extension SnodeAPI { - enum Namespace: Int, Codable { + enum Namespace: Int, Codable, Hashable { case `default` = 0 - case userProfileConfig = 2 + case configUserProfile = 2 + case configContacts = 3 + case configClosedGroupInfo = 11 case legacyClosedGroup = -10 diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 4eb224273..b4412595d 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -65,14 +65,12 @@ public extension Identity { ) } - static func store(seed: Data, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) { - Storage.shared.write { db in - try Identity(variant: .seed, data: seed).save(db) - try Identity(variant: .ed25519SecretKey, data: Data(ed25519KeyPair.secretKey)).save(db) - try Identity(variant: .ed25519PublicKey, data: Data(ed25519KeyPair.publicKey)).save(db) - try Identity(variant: .x25519PrivateKey, data: Data(x25519KeyPair.secretKey)).save(db) - try Identity(variant: .x25519PublicKey, data: Data(x25519KeyPair.publicKey)).save(db) - } + static func store(_ db: Database, seed: Data, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) throws { + try Identity(variant: .seed, data: seed).save(db) + try Identity(variant: .ed25519SecretKey, data: Data(ed25519KeyPair.secretKey)).save(db) + try Identity(variant: .ed25519PublicKey, data: Data(ed25519KeyPair.publicKey)).save(db) + try Identity(variant: .x25519PrivateKey, data: Data(x25519KeyPair.secretKey)).save(db) + try Identity(variant: .x25519PublicKey, data: Data(x25519KeyPair.publicKey)).save(db) } static func userExists(_ db: Database? = nil) -> Bool { diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 037d83fc3..84bd5b970 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -102,6 +102,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// This is a job that runs once whenever an attachment is downloaded to attempt to decode and properly /// download the attachment case attachmentDownload + + /// This is a job that runs once whenever the user config or a closed group config changes, it retrieves the + /// state of all config objects and syncs any that are flagged as needing to be synced + case configurationSync } public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable { diff --git a/SessionUtilitiesKit/General/Features.swift b/SessionUtilitiesKit/General/Features.swift index d23dabef6..0584e131c 100644 --- a/SessionUtilitiesKit/General/Features.swift +++ b/SessionUtilitiesKit/General/Features.swift @@ -1,6 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(SNFeatures) -public final class Features : NSObject { - public static let useOnionRequests = true - public static let useTestnet = false +import Foundation + +public final class Features { + public static let useOnionRequests: Bool = true + public static let useTestnet: Bool = false + + public static let useSharedUtilForUserConfig: Bool = true } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 71e4d7b3a..a09c282c6 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -65,7 +65,8 @@ public final class JobRunner { jobVariants.remove(.attachmentUpload), jobVariants.remove(.messageSend), jobVariants.remove(.notifyPushServer), - jobVariants.remove(.sendReadReceipts) + jobVariants.remove(.sendReadReceipts), + jobVariants.remove(.configurationSync) ].compactMap { $0 } ) let messageReceiveQueue: JobQueue = JobQueue( diff --git a/SessionUtilitiesKit/Media/Updatable.swift b/SessionUtilitiesKit/Media/Updatable.swift deleted file mode 100644 index 4a4a39495..000000000 --- a/SessionUtilitiesKit/Media/Updatable.swift +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public enum Updatable: ExpressibleByNilLiteral { - /// A cleared value. - /// - /// In code, the cleared of a value is typically written using the `nil` - /// literal rather than the explicit `.remove` enumeration case. - case remove - - /// The existing value, this will leave whatever value is currently available. - case existing - - /// An updated value, stored as `Wrapped`. - case update(Wrapped) - - // MARK: - ExpressibleByNilLiteral - - public init(nilLiteral: ()) { - self = .remove - } - - public static func updateIf(_ maybeValue: Wrapped?) -> Updatable { - switch maybeValue { - case .some(let value): return .update(value) - default: return .existing - } - } - - public static func updateTo(_ maybeValue: Wrapped?) -> Updatable { - switch maybeValue { - case .some(let value): return .update(value) - default: return .remove - } - } - - // MARK: - Functions - - public func value(existing: Wrapped) -> Wrapped? { - switch self { - case .remove: return nil - case .existing: return existing - case .update(let newValue): return newValue - } - } - - public func value(existing: Wrapped) -> Wrapped { - switch self { - case .remove: fatalError("Attempted to assign a 'removed' value to a non-null") - case .existing: return existing - case .update(let newValue): return newValue - } - } -} - -// MARK: - Coalesing-nil operator - -public func ?? (updatable: Updatable, existingValue: @autoclosure () throws -> T) rethrows -> T { - switch updatable { - case .remove: fatalError("Attempted to assign a 'removed' value to a non-null") - case .existing: return try existingValue() - case .update(let newValue): return newValue - } -} - -public func ?? (updatable: Updatable, existingValue: @autoclosure () throws -> T?) rethrows -> T? { - switch updatable { - case .remove: return nil - case .existing: return try existingValue() - case .update(let newValue): return newValue - } -} - -public func ?? (updatable: Updatable>, existingValue: @autoclosure () throws -> T?) rethrows -> T? { - switch updatable { - case .remove: return nil - case .existing: return try existingValue() - case .update(let newValue): return newValue - } -} - -// MARK: - ExpressibleBy Conformance - -extension Updatable { - public init(_ value: Wrapped) { - self = .update(value) - } -} - -extension Updatable: ExpressibleByUnicodeScalarLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByStringLiteral where Wrapped == String { - public init(stringLiteral value: Wrapped) { - self = .update(value) - } - - public init(extendedGraphemeClusterLiteral value: Wrapped) { - self = .update(value) - } - - public init(unicodeScalarLiteral value: Wrapped) { - self = .update(value) - } -} - -extension Updatable: ExpressibleByIntegerLiteral where Wrapped == Int { - public init(integerLiteral value: Int) { - self = .update(value) - } -} - -extension Updatable: ExpressibleByFloatLiteral where Wrapped == Double { - public init(floatLiteral value: Double) { - self = .update(value) - } -} - -extension Updatable: ExpressibleByBooleanLiteral where Wrapped == Bool { - public init(booleanLiteral value: Bool) { - self = .update(value) - } -} diff --git a/SessionUtilitiesKit/Networking/BatchResponse.swift b/SessionUtilitiesKit/Networking/BatchResponse.swift index da3ccdbfa..8b83fc6c2 100644 --- a/SessionUtilitiesKit/Networking/BatchResponse.swift +++ b/SessionUtilitiesKit/Networking/BatchResponse.swift @@ -4,16 +4,20 @@ import Foundation import Combine public extension HTTP { - // MARK: - Convenience Aliases - - typealias BatchResponse = [(ResponseInfoType, Codable?)] typealias BatchResponseTypes = [Codable.Type] + // MARK: - BatchResponse + + struct BatchResponse { + public let info: ResponseInfoType + public let responses: [Codable] + } + // MARK: - BatchSubResponse - struct BatchSubResponse: Codable { + struct BatchSubResponse: BatchSubResponseType { /// The numeric http response code (e.g. 200 for success) - public let code: Int32 + public let code: Int /// Any headers returned by the request public let headers: [String: String] @@ -25,7 +29,7 @@ public extension HTTP { public let failedToParseBody: Bool public init( - code: Int32, + code: Int, headers: [String: String] = [:], body: T? = nil, failedToParseBody: Bool = false @@ -38,13 +42,23 @@ public extension HTTP { } } +public protocol BatchSubResponseType: Codable { + var code: Int { get } + var headers: [String: String] { get } + var failedToParseBody: Bool { get } +} + +extension BatchSubResponseType { + public var responseInfo: ResponseInfoType { HTTP.ResponseInfo(code: code, headers: headers) } +} + public extension HTTP.BatchSubResponse { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) let body: T? = try? container.decode(T.self, forKey: .body) self = HTTP.BatchSubResponse( - code: try container.decode(Int32.self, forKey: .code), + code: try container.decode(Int.self, forKey: .code), headers: ((try? container.decode([String: String].self, forKey: .headers)) ?? [:]), body: body, failedToParseBody: ( @@ -111,13 +125,15 @@ public extension AnyPublisher where Output == (ResponseInfoType, Data?), Failure 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() + return Just( + HTTP.BatchResponse( + info: responseInfo, + responses: try Swift.zip(dataArray, types) + .map { data, type in try type.decoded(from: data, using: dependencies) } + ) + ) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } catch { return Fail(error: HTTPError.parsingFailed) diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index a0d5b7e23..641e7726a 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -77,9 +77,12 @@ public enum AppSetup { // After the migrations have run but before the migration completion we load the // SessionUtil state and update the 'needsConfigSync' flag based on whether the // configs also need to be sync'ed - SessionUtil.loadState( - ed25519SecretKey: Identity.fetchUserEd25519KeyPair()?.secretKey - ) + if Identity.userExists() { + SessionUtil.loadState( + userPublicKey: getUserHexEncodedPublicKey(), + ed25519SecretKey: Identity.fetchUserEd25519KeyPair()?.secretKey + ) + } DispatchQueue.main.async { migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync))