From b6328f79b99b9c4de1fd962f39f37be58d6812da Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 27 Jun 2023 18:01:00 +1000 Subject: [PATCH 1/3] Reworked the app startup process Shifted the initial HomeVC population to a background thread to avoid blocking launch processing Added some logging for database 'ABORT' errors to better identify cases of deadlocks Added a launch timeout modal to allow users to share their logs if the startup process happens to hang Updated the notification handling (and cancelling) so it could run on background threads (seemed to take up a decent chunk of main thread time) Fixed an issue where the IP2Country population was running sync which could cause a hang on startup Fixed an issue where the code checking if the UIPasteBoard contained an image was explicitly advised against by the documentation (caused some reported hangs) Fixed a hang which could be caused by a redundant function when the ImagePickerController appeared --- Session/Conversations/ConversationVC.swift | 30 +- .../Conversations/ConversationViewModel.swift | 10 +- .../Input View/InputTextView.swift | 2 +- Session/Home/HomeVC.swift | 29 +- Session/Home/HomeViewModel.swift | 34 +- .../MessageRequestsViewController.swift | 11 +- .../MessageRequestsViewModel.swift | 10 +- .../ImagePickerController.swift | 29 +- .../MediaGalleryViewModel.swift | 10 +- .../MediaPageViewController.swift | 22 +- Session/Meta/AppDelegate.swift | 362 ++++++++++++------ Session/Meta/SessionApp.swift | 24 ++ .../Translations/de.lproj/Localizable.strings | 2 + .../Translations/en.lproj/Localizable.strings | 2 + .../Translations/es.lproj/Localizable.strings | 2 + .../Translations/fa.lproj/Localizable.strings | 2 + .../Translations/fi.lproj/Localizable.strings | 2 + .../Translations/fr.lproj/Localizable.strings | 2 + .../Translations/hi.lproj/Localizable.strings | 2 + .../Translations/hr.lproj/Localizable.strings | 2 + .../id-ID.lproj/Localizable.strings | 2 + .../Translations/it.lproj/Localizable.strings | 2 + .../Translations/ja.lproj/Localizable.strings | 2 + .../Translations/nl.lproj/Localizable.strings | 2 + .../Translations/pl.lproj/Localizable.strings | 2 + .../pt_BR.lproj/Localizable.strings | 2 + .../Translations/ru.lproj/Localizable.strings | 2 + .../Translations/si.lproj/Localizable.strings | 2 + .../Translations/sk.lproj/Localizable.strings | 2 + .../Translations/sv.lproj/Localizable.strings | 2 + .../Translations/th.lproj/Localizable.strings | 2 + .../vi-VN.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../zh_CN.lproj/Localizable.strings | 2 + Session/Notifications/AppNotifications.swift | 240 ++++++------ .../UserNotificationsAdaptee.swift | 70 ++-- Session/Onboarding/Onboarding.swift | 19 + Session/Settings/HelpViewModel.swift | 21 +- Session/Utilities/IP2Country.swift | 15 +- .../MessageReceiver+Calls.swift | 10 +- .../MessageReceiver+VisibleMessages.swift | 9 +- .../Notifications/NotificationsProtocol.swift | 6 +- .../Utilities/DeviceSleepManager.swift | 6 +- .../NSENotificationPresenter.swift | 6 +- .../NotificationServiceExtension.swift | 3 +- SessionShareExtension/ThreadPickerVC.swift | 16 +- SessionUtilitiesKit/Database/Storage.swift | 36 +- SignalUtilitiesKit/Utilities/AppSetup.swift | 10 +- .../Utilities/NoopNotificationsManager.swift | 6 +- 49 files changed, 687 insertions(+), 403 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index ddadaed3e..6e95bce32 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -13,7 +13,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers private static let loadingHeaderHeight: CGFloat = 40 internal let viewModel: ConversationViewModel - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var hasLoadedInitialThreadData: Bool = false private var hasLoadedInitialInteractionData: Bool = false private var currentTargetOffset: CGPoint? @@ -518,6 +520,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the + /// main thread (we don't currently care if it's still in the nav stack though - so if a user is on a conversation settings screen this should + /// get cleared within `viewWillDisappear`) + /// + /// **Note:** We do this on an async queue because `Atomic` can block if something else is mutating it and we want to avoid + /// the risk of blocking the conversation transition + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + SessionApp.currentlyOpenConversationViewController.mutate { $0 = self } + } + if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false @@ -540,6 +552,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the + /// main thread (we don't currently care if it's still in the nav stack though - so if a user leaves a conversation settings screen we clear + /// it, and if a user moves to a different `ConversationVC` this will get updated to that one within `viewDidAppear`) + /// + /// **Note:** We do this on an async queue because `Atomic` can block if something else is mutating it and we want to avoid + /// the risk of blocking the conversation transition + DispatchQueue.global(qos: .userInitiated).async { + SessionApp.currentlyOpenConversationViewController.mutate { $0 = nil } + } + viewIsDisappearing = true // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard @@ -605,7 +627,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers // MARK: - Updating private func startObservingChanges(didReturnFromBackground: Bool = false) { - // Start observing for data changes + guard dataChangeObservable == nil else { return } + dataChangeObservable = Storage.shared.start( viewModel.observableThreadData, onError: { _ in }, @@ -675,8 +698,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers } func stopObservingChanges() { - // Stop observing database changes - dataChangeObservable?.cancel() + self.dataChangeObservable = nil self.viewModel.onInteractionChange = nil } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 0c898f941..a5dcf5086 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -217,8 +217,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges { - onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedInteractionDataChanges = nil } } diff --git a/Session/Conversations/Input View/InputTextView.swift b/Session/Conversations/Input View/InputTextView.swift index aaaf0e3f0..cba081e8e 100644 --- a/Session/Conversations/Input View/InputTextView.swift +++ b/Session/Conversations/Input View/InputTextView.swift @@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate { public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { - if let _ = UIPasteboard.general.image { + if UIPasteboard.general.hasImages { return true } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index eb7d34ec5..d62e359f0 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -13,7 +13,9 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData public static let newConversationButtonSize: CGFloat = 60 private let viewModel: HomeViewModel = HomeViewModel() - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var hasLoadedInitialStateData: Bool = false private var hasLoadedInitialThreadData: Bool = false private var isLoadingMore: Bool = false @@ -327,26 +329,31 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData // MARK: - Updating - private func startObservingChanges(didReturnFromBackground: Bool = false) { - // Start observing for data changes + public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) { + guard dataChangeObservable == nil else { return } + + var runAndClearInitialChangeCallback: (() -> ())? = nil + + runAndClearInitialChangeCallback = { [weak self] in + guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return } + + onReceivedInitialChange?() + runAndClearInitialChangeCallback = nil + } + dataChangeObservable = Storage.shared.start( viewModel.observableState, - // If we haven't done the initial load the trigger it immediately (blocking the main - // thread so we remain on the launch screen until it completes to be consistent with - // the old behaviour) - scheduling: (hasLoadedInitialStateData ? - .async(onQueue: .main) : - .immediate - ), onError: { _ in }, onChange: { [weak self] state in // The default scheduler emits changes on the main thread self?.handleUpdates(state) + runAndClearInitialChangeCallback?() } ) self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in self?.handleThreadUpdates(updatedThreadData, changeset: changeset) + runAndClearInitialChangeCallback?() } // Note: When returning from the background we could have received notifications but the @@ -361,7 +368,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData private func stopObservingChanges() { // Stop observing database changes - dataChangeObservable?.cancel() + self.dataChangeObservable = nil self.viewModel.onThreadChange = nil } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index d55d186b6..675317605 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -208,12 +208,16 @@ public class HomeViewModel { ) } ) + + self?.hasReceivedInitialThreadData = true } ) - // Run the initial query on the main thread so we prevent the app from leaving the loading screen - // until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page) - self.pagedDataObserver?.load(.pageBefore) + // Run the initial query on a background thread so we don't block the main thread + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + // The `.pageBefore` will query from a `0` offset loading the first page + self?.pagedDataObserver?.load(.pageBefore) + } } // MARK: - State @@ -254,8 +258,10 @@ public class HomeViewModel { let oldState: State = self.state self.state = updatedState - // If the messageRequest content changed then we need to re-process the thread data + // If the messageRequest content changed then we need to re-process the thread data (assuming + // we've received the initial thread data) guard + self.hasReceivedInitialThreadData, ( oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests || oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount @@ -272,11 +278,7 @@ public class HomeViewModel { PagedData.processAndTriggerUpdates( updatedData: updatedThreadData, - currentDataRetriever: { [weak self] in - guard self?.hasProcessedInitialThreadData == true else { return nil } - - return (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) - }, + currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) }, onDataChange: onThreadChange, onUnobservedDataChange: { [weak self] updatedData, changeset in self?.unobservedThreadDataChanges = (changeset.isEmpty ? @@ -289,19 +291,23 @@ public class HomeViewModel { // MARK: - Thread Data - private var hasProcessedInitialThreadData: Bool = false + private var hasReceivedInitialThreadData: Bool = false public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var threadData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { didSet { - self.hasProcessedInitialThreadData = (onThreadChange != nil || hasProcessedInitialThreadData) - // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { - onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedThreadDataChanges = nil } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 7d6b158e0..b7c63b57d 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -11,7 +11,6 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController private static let loadingHeaderHeight: CGFloat = 40 private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel() - private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialThreadData: Bool = false private var isLoadingMore: Bool = false private var isAutoLoadingNextPage: Bool = false @@ -161,8 +160,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -173,8 +171,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: - Layout @@ -223,6 +220,10 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController } } + private func stopObservingChanges() { + self.viewModel.onThreadChange = nil + } + private func handleThreadUpdates( _ updatedData: [MessageRequestsViewModel.SectionModel], changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>, diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index a7ed46b98..27c426335 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -129,8 +129,14 @@ public class MessageRequestsViewModel { didSet { // When starting to observe interaction changes we want to trigger a UI update just in case the // data was changed while we weren't observing - if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { - self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedThreadDataChanges = nil } } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 504465c31..e7837f15a 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -203,8 +203,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let scale = UIScreen.main.scale let cellSize = collectionViewFlowLayout.itemSize photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) - - reloadDataAndRestoreSelection() + if !hasEverAppeared { scrollToBottom(animated: false) } @@ -291,30 +290,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } } - private func reloadDataAndRestoreSelection() { - guard let collectionView = collectionView else { - owsFailDebug("Missing collectionView.") - return - } - - guard let delegate = delegate else { - owsFailDebug("delegate was unexpectedly nil") - return - } - - collectionView.reloadData() - collectionView.layoutIfNeeded() - - let count = photoCollectionContents.assetCount - for index in 0..) = self.unobservedGalleryDataChanges { - onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onGalleryChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedGalleryDataChanges = nil } } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 1125f5eaa..59cf1a58f 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -15,7 +15,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss? public let viewModel: MediaGalleryViewModel - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var initialPage: MediaDetailViewController private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:] @@ -40,7 +42,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou ) // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: item.interactionId) startObservingChanges() @@ -238,8 +240,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() resignFirstResponder() } @@ -252,8 +253,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -388,6 +388,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: - Updating private func startObservingChanges() { + guard dataChangeObservable == nil else { return } + // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableAlbumData, @@ -399,6 +401,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou ) } + private func stopObservingChanges() { + dataChangeObservable = nil + } + private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) { // Determine if we swapped albums (if so we don't need to do anything else) guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else { @@ -710,7 +716,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter) startObservingChanges() @@ -755,7 +761,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore) startObservingChanges() diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index ca106f141..ae7592fef 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -13,23 +13,25 @@ import SignalCoreKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 5 + var window: UIWindow? var backgroundSnapshotBlockerWindow: UIWindow? var appStartupWindow: UIWindow? var hasInitialRootViewController: Bool = false + var startTime: CFTimeInterval = 0 private var loadingViewController: LoadingViewController? - enum LifecycleMethod { - case finishLaunching - case enterForeground - } - /// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used lazy var poller: CurrentUserPoller = CurrentUserPoller() // MARK: - Lifecycle func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Log something immediately to make it easier to track app launches (and crashes during launch) + SNLog("Launching \(SessionApp.versionInfo)") + startTime = CACurrentMediaTime() + // These should be the first things we do (the startup process can fail without them) SetCurrentAppContext(MainAppContext()) verifyDBKeysAvailableBeforeBackgroundLaunch() @@ -71,7 +73,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: .finishLaunching, error: error) + self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .migrationError(error)) return } @@ -147,7 +149,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: .enterForeground, error: error) + DispatchQueue.main.async { + self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .migrationError(error)) + } return } @@ -187,7 +191,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD UserDefaults.sharedLokiProject?[.isMainAppActive] = true - ensureRootViewController() + ensureRootViewController(calledFrom: .didBecomeActive) AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in self?.handleActivation() @@ -283,122 +287,139 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Configuration.performMainSetup() JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) - /// Setup the UI - /// - /// **Note:** This **MUST** be run before calling: - /// - `AppReadiness.setAppIsReady()`: - /// If we are launching the app from a push notification the HomeVC won't be setup yet - /// and it won't open the related thread - /// - /// - `JobRunner.appDidFinishLaunching()`: - /// The jobs which run on launch (eg. DisappearingMessages job) can impact the interactions - /// which get fetched to display on the home screen, if the PagedDatabaseObserver hasn't - /// been setup yet then the home screen can show stale (ie. deleted) interactions incorrectly - self.ensureRootViewController(isPreAppReadyCall: true) - - // Trigger any launch-specific jobs and start the JobRunner - if lifecycleMethod == .finishLaunching { - JobRunner.appDidFinishLaunching() - } - - // Note that this does much more than set a flag; - // it will also run all deferred blocks (including the JobRunner - // 'appDidBecomeActive' method) - AppReadiness.setAppIsReady() - - DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) - AppVersion.sharedInstance().mainAppLaunchDidComplete() - Environment.shared?.audioSession.setup() - Environment.shared?.reachabilityManager.setup() - - Storage.shared.writeAsync { db in - // Disable the SAE until the main app has successfully completed launch process - // at least once in the post-SAE world. - db[.isReadyForAppExtensions] = true + // Setup the UI and trigger any post-UI setup actions + self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] in + /// Trigger any launch-specific jobs and start the JobRunner with `JobRunner.appDidFinishLaunching()` some + /// of these jobs (eg. DisappearingMessages job) can impact the interactions which get fetched to display on the home + /// screen, if the PagedDatabaseObserver hasn't been setup yet then the home screen can show stale (ie. deleted) + /// interactions incorrectly + if lifecycleMethod == .finishLaunching { + JobRunner.appDidFinishLaunching() + } - if Identity.userCompletedRequiredOnboarding(db) { - let appVersion: AppVersion = AppVersion.sharedInstance() + /// Flag that the app is ready via `AppReadiness.setAppIsReady()` + /// + /// If we are launching the app from a push notification we need to ensure we wait until after the `HomeVC` is setup + /// otherwise it won't open the related thread + /// + /// **Note:** This this does much more than set a flag - it will also run all deferred blocks (including the JobRunner + /// `appDidBecomeActive` method hence why it **must** also come after calling + /// `JobRunner.appDidFinishLaunching()`) + AppReadiness.setAppIsReady() + + /// Remove the sleep blocking once the startup is done (needs to run on the main thread and sleeping while + /// doing the startup could suspend the database causing errors/crashes + DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) + + /// App launch hasn't really completed until the main screen is loaded so wait until then to register it + AppVersion.sharedInstance().mainAppLaunchDidComplete() + + /// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup + Storage.shared.writeAsync { db in + // Disable the SAE until the main app has successfully completed launch process + // at least once in the post-SAE world. + db[.isReadyForAppExtensions] = true - // If the device needs to sync config or the user updated to a new version - if - needsConfigSync || ( - (appVersion.lastAppVersion?.count ?? 0) > 0 && - appVersion.lastAppVersion != appVersion.currentAppVersion - ) - { - ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) + if Identity.userCompletedRequiredOnboarding(db) { + let appVersion: AppVersion = AppVersion.sharedInstance() + + // If the device needs to sync config or the user updated to a new version + if + needsConfigSync || ( + (appVersion.lastAppVersion?.count ?? 0) > 0 && + appVersion.lastAppVersion != appVersion.currentAppVersion + ) + { + ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) + } } } + + // Add a log to track the proper startup time of the app so we know whether we need to + // improve it in the future from user logs + let endTime: CFTimeInterval = CACurrentMediaTime() + SNLog("Launch completed in \((self?.startTime).map { ceil((endTime - $0) * 1000) } ?? -1)ms") } + + // May as well run these on the background thread + Environment.shared?.audioSession.setup() + Environment.shared?.reachabilityManager.setup() } - private func showFailedMigrationAlert( + private func showFailedStartupAlert( calledFrom lifecycleMethod: LifecycleMethod, - error: Error?, - isRestoreError: Bool = false + error: StartupError, + animated: Bool = true, + presentationCompletion: (() -> ())? = nil ) { - let alert = UIAlertController( + /// This **must** be a standard `UIAlertController` instead of a `ConfirmationModal` because we may not + /// have access to the database when displaying this so can't extract theme information for styling purposes + let alert: UIAlertController = UIAlertController( title: "Session", - message: { - switch (isRestoreError, (error ?? StorageError.generic)) { - case (true, _): return "DATABASE_RESTORE_FAILED".localized() - case (_, StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized() - default: return "DATABASE_MIGRATION_FAILED".localized() - } - }(), + message: error.message, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in - self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error) + // Don't bother showing the "Failed Startup" modal again if we happen to now + // have an initial view controller (this most likely means that the startup + // completed while the user was sharing logs so we can just let the user use + // the app) + guard self?.hasInitialRootViewController == false else { return } + + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: error) } }) - // Only offer the 'Restore' option if the user hasn't already tried to restore - if !isRestoreError { - alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in - if SUKLegacy.hasLegacyDatabaseFile { - // Remove the legacy database and any message hashes that have been migrated to the new DB - try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - - Storage.shared.write { db in - try SnodeReceivedMessageInfo.deleteAll(db) - } - } - else { - // If we don't have a legacy database then reset the current database for a clean migration - Storage.resetForCleanMigration() - } - - // Hide the top banner if there was one - TopBannerController.hide() - - // The re-run the migration (should succeed since there is no data) - AppSetup.runPostSetupMigrations( - migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in - self?.loadingViewController?.updateProgress( - progress: progress, - minEstimatedTotalTime: minEstimatedTotalTime - ) - }, - migrationsCompletion: { [weak self] result, needsConfigSync in - if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error, isRestoreError: true) - return - } + switch error { + // Offer the 'Restore' option if it was a migration error + case .migrationError: + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + if SUKLegacy.hasLegacyDatabaseFile { + // Remove the legacy database and any message hashes that have been migrated to the new DB + try? SUKLegacy.deleteLegacyDatabaseFilesAndKey() - self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) + Storage.shared.write { db in + try SnodeReceivedMessageInfo.deleteAll(db) + } } - ) - }) + else { + // If we don't have a legacy database then reset the current database for a clean migration + Storage.resetForCleanMigration() + } + + // Hide the top banner if there was one + TopBannerController.hide() + + // The re-run the migration (should succeed since there is no data) + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { [weak self] result, needsConfigSync in + switch result { + case .failure: + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore) + + case .success: + self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) + } + } + ) + }) + + default: break } - alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in + alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in DDLog.flushLog() exit(0) }) - self.window?.rootViewController?.present(alert, animated: true, completion: nil) + self.window?.rootViewController?.present(alert, animated: animated, completion: presentationCompletion) } /// The user must unlock the device once after reboot before the database encryption key can be accessed. @@ -452,36 +473,101 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func ensureRootViewController(isPreAppReadyCall: Bool = false) { - guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else { + private func ensureRootViewController( + calledFrom lifecycleMethod: LifecycleMethod, + onComplete: (() -> ())? = nil + ) { + guard (AppReadiness.isAppReady() || lifecycleMethod == .finishLaunching) && Storage.shared.isValid && !hasInitialRootViewController else { return } - self.hasInitialRootViewController = true - self.window?.rootViewController = TopBannerController( - child: StyledNavigationController( - rootViewController: { - guard Identity.userExists() else { return LandingVC() } - guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else { - // If we have no display name then collect one (this can happen if the - // app crashed during onboarding which would leave the user in an invalid - // state with no display name) - return DisplayNameVC(flow: .register) - } - - return HomeVC() - }() - ), - cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow] - .map { rawValue in TopBannerController.Warning(rawValue: rawValue) } - ) - UIViewController.attemptRotationToDeviceOrientation() + /// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user + /// the option to export their logs) + let populateHomeScreenTimer: Timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: AppDelegate.maxRootViewControllerInitialQueryDuration, + repeats: false + ) { [weak self] timer in + timer.invalidate() + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout) + } - /// **Note:** There is an annoying case when starting the app by interacting with a push notification where - /// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController` - /// won't have been set - we set the value directly here to resolve this edge case - if let homeViewController: HomeVC = (self.window?.rootViewController as? UINavigationController)?.viewControllers.first as? HomeVC { - SessionApp.homeViewController.mutate { $0 = homeViewController } + // All logic which needs to run after the 'rootViewController' is created + let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in + let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController + let targetRootViewController: UIViewController = TopBannerController( + child: StyledNavigationController(rootViewController: rootViewController), + cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow] + .map { rawValue in TopBannerController.Warning(rawValue: rawValue) } + ) + + /// Insert the `targetRootViewController` below the current view and trigger a layout without animation before properly + /// swapping the `rootViewController` over so we can avoid any weird initial layout behaviours + UIView.performWithoutAnimation { + self?.window?.rootViewController = targetRootViewController + } + + self?.hasInitialRootViewController = true + UIViewController.attemptRotationToDeviceOrientation() + + /// **Note:** There is an annoying case when starting the app by interacting with a push notification where + /// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController` + /// won't have been set - we set the value directly here to resolve this edge case + if let homeViewController: HomeVC = rootViewController as? HomeVC { + SessionApp.homeViewController.mutate { $0 = homeViewController } + } + + /// If we were previously presenting a viewController but are no longer preseting it then present it again + /// + /// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or + /// was previously?) presented, even if it's not attached to the screen it seems... + switch presentedViewController { + case is UIAlertController, is ConfirmationModal: + /// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it + /// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear) + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) { + self?.window?.rootViewController?.dismiss(animated: true) + } + + case is UIActivityViewController: HelpViewModel.shareLogs(animated: false) + default: break + } + + // Setup is completed so run any post-setup tasks + onComplete?() + } + + // Navigate to the approriate screen depending on the onboarding state + switch Onboarding.State.current { + case .newUser: + DispatchQueue.main.async { + let viewController: LandingVC = LandingVC() + populateHomeScreenTimer.invalidate() + rootViewControllerSetupComplete(viewController) + } + + case .missingName: + DispatchQueue.main.async { + let viewController: DisplayNameVC = DisplayNameVC(flow: .register) + populateHomeScreenTimer.invalidate() + rootViewControllerSetupComplete(viewController) + } + + case .completed: + DispatchQueue.main.async { + let viewController: HomeVC = HomeVC() + + /// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we + /// continue as we don't want to show a blank home screen + DispatchQueue.global(qos: .userInitiated).async { + viewController.startObservingChanges() { + populateHomeScreenTimer.invalidate() + + DispatchQueue.main.async { + rootViewControllerSetupComplete(viewController) + } + } + } + } } } @@ -750,3 +836,29 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD ) } } + +// MARK: - LifecycleMethod + +private enum LifecycleMethod { + case finishLaunching + case enterForeground + case didBecomeActive +} + +// MARK: - StartupError + +private enum StartupError: Error { + case databaseStartupError + case migrationError(Error) + case failedToRestore + case startupTimeout + + var message: String { + switch self { + case .databaseStartupError: return "DATABASE_STARTUP_FAILED".localized() + case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized() + case .migrationError: return "DATABASE_MIGRATION_FAILED".localized() + case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized() + } + } +} diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 2adb9eb7c..5dfc97351 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -6,7 +6,31 @@ import SessionMessagingKit import SignalCoreKit public struct SessionApp { + // FIXME: Refactor this to be protocol based for unit testing (or even dynamic based on view hierarchy - do want to avoid needing to use the main thread to access them though) static let homeViewController: Atomic = Atomic(nil) + static let currentlyOpenConversationViewController: Atomic = Atomic(nil) + + static var versionInfo: String { + let buildNumber: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) + .map { " (\($0))" } + .defaulting(to: "") + let appVersion: String? = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + .map { "App: \($0)\(buildNumber)" } + #if DEBUG + let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" } + #else + let commitInfo: String? = nil + #endif + + let versionInfo: [String] = [ + "iOS \(UIDevice.current.systemVersion)", + appVersion, + "libSession: \(SessionUtil.libSessionVersion)", + commitInfo + ].compactMap { $0 } + + return versionInfo.joined(separator: ", ") + } // MARK: - View Convenience Methods diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 178d7c6ea..688070b13 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 2e5c23059..8f0e8f077 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 4d1257d37..81652e295 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 37be0ffb3..9ca92b73c 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید"; "LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود."; "RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 61f77cd4a..c63940d0a 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index b5bf78948..1b1005b26 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; "LOADING_CONVERSATIONS" = "Chargement des conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; "RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 2b5a37525..534ff51ef 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index c7714aca6..39506e93f 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 62d0c3471..a19d65df2 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index acb886818..101c5112a 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index e5ffab88b..6de3a1708 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 2597356f0..3f071ad99 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 190af0988..dd56d5cd4 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 3f9740a6e..bdd6a7f9a 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 648eb63da..bfaf6bb8c 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 2e06c99a0..c35173c5b 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index b140447f4..8e5f5db93 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index dfb732242..dfdb07bd1 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index c720946db..0431a2c30 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 4efaa9b08..cbe4e9604 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index f8735880c..2c1c6c1d0 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index b2a05e36b..d69d20a71 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -415,6 +415,8 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; "DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; "DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 4c818024b..314fee3b5 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -99,6 +99,7 @@ protocol NotificationPresenterAdaptee: AnyObject { sound: Preferences.Sound?, threadVariant: SessionThread.Variant, threadName: String, + applicationState: UIApplication.State, replacingIdentifier: String? ) @@ -116,7 +117,8 @@ extension NotificationPresenterAdaptee { previewType: Preferences.NotificationPreviewType, sound: Preferences.Sound?, threadVariant: SessionThread.Variant, - threadName: String + threadName: String, + applicationState: UIApplication.State ) { notify( category: category, @@ -127,22 +129,16 @@ extension NotificationPresenterAdaptee { sound: sound, threadVariant: threadVariant, threadName: threadName, + applicationState: applicationState, replacingIdentifier: nil ) } } -@objc(OWSNotificationPresenter) -public class NotificationPresenter: NSObject, NotificationsProtocol { - - private let adaptee: NotificationPresenterAdaptee - - @objc - public override init() { - self.adaptee = UserNotificationPresenterAdaptee() - - super.init() +public class NotificationPresenter: NotificationsProtocol { + private let adaptee: NotificationPresenterAdaptee = UserNotificationPresenterAdaptee() + public init() { SwiftSingletons.register(self) } @@ -152,7 +148,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return adaptee.registerNotificationSettings() } - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + for interaction: Interaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -244,34 +245,39 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - DispatchQueue.main.async { - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - notificationBody = MentionUtilities.highlightMentionsNoAttributes( - in: (notificationBody ?? ""), - threadVariant: thread.variant, - currentUserPublicKey: userPublicKey, - currentUserBlindedPublicKey: userBlindedKey - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: groupName, - replacingIdentifier: identifier - ) - } + let sound: Preferences.Sound? = requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + notificationBody = MentionUtilities.highlightMentionsNoAttributes( + in: (notificationBody ?? ""), + threadVariant: thread.variant, + currentUserPublicKey: userPublicKey, + currentUserBlindedPublicKey: userBlindedKey + ) + + self.adaptee.notify( + category: category, + title: notificationTitle, + body: (notificationBody ?? ""), + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: groupName, + applicationState: applicationState, + replacingIdentifier: identifier + ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + forIncomingCall interaction: Interaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard @@ -320,28 +326,32 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) + let sound = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) - DispatchQueue.main.async { - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: senderName, - replacingIdentifier: UUID().uuidString - ) - } + self.adaptee.notify( + category: category, + title: notificationTitle, + body: (notificationBody ?? ""), + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: senderName, + applicationState: applicationState, + replacingIdentifier: UUID().uuidString + ) } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + forReaction reaction: Reaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // No reaction notifications for muted, group threads or message requests @@ -380,28 +390,31 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ) let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - - DispatchQueue.main.async { - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - replacingIdentifier: UUID().uuidString - ) - } + let sound = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + self.adaptee.notify( + category: category, + title: notificationTitle, + body: notificationBody, + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: threadName, + applicationState: applicationState, + replacingIdentifier: UUID().uuidString + ) } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread) { + public func notifyForFailedSend( + _ db: Database, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let notificationTitle: String? let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .defaultPreviewType) @@ -432,24 +445,23 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ] let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - - DispatchQueue.main.async { - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: .errorMessage, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName - ) - } + let sound: Preferences.Sound? = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + self.adaptee.notify( + category: .errorMessage, + title: notificationTitle, + body: notificationBody, + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: threadName, + applicationState: applicationState + ) } @objc @@ -471,32 +483,30 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // MARK: - - var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) + var mostRecentNotifications: Atomic> = Atomic(TruncatedList(maxLength: kAudioNotificationsThrottleCount)) - private func requestSound(thread: SessionThread, fallbackSound: Preferences.Sound) -> Preferences.Sound? { - guard checkIfShouldPlaySound() else { - return nil - } + private func requestSound( + thread: SessionThread, + fallbackSound: Preferences.Sound, + applicationState: UIApplication.State + ) -> Preferences.Sound? { + guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil } return (thread.notificationSound ?? fallbackSound) } - private func checkIfShouldPlaySound() -> Bool { - AssertIsOnMainThread() - - guard UIApplication.shared.applicationState == .active else { return true } + private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool { + guard applicationState == .active else { return true } guard Storage.shared[.playNotificationSoundInForeground] else { return false } let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) - let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } + let recentNotifications = mostRecentNotifications.wrappedValue.filter { $0 > recentThreshold } - guard recentNotifications.count < kAudioNotificationsThrottleCount else { - return false - } + guard recentNotifications.count < kAudioNotificationsThrottleCount else { return false } - mostRecentNotifications.append(nowMs) + mostRecentNotifications.mutate { $0.append(nowMs) } return true } } @@ -527,7 +537,11 @@ class NotificationActionHandler { return markAsRead(threadId: threadId) } - func reply(userInfo: [AnyHashable: Any], replyText: String) -> AnyPublisher { + func reply( + userInfo: [AnyHashable: Any], + replyText: String, + applicationState: UIApplication.State + ) -> AnyPublisher { guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) .eraseToAnyPublisher() @@ -582,7 +596,11 @@ class NotificationActionHandler { case .finished: break case .failure: Storage.shared.read { [weak self] db in - self?.notificationPresenter.notifyForFailedSend(db, in: thread) + self?.notificationPresenter.notifyForFailedSend( + db, + in: thread, + applicationState: applicationState + ) } } } diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 4e8711b3e..15c14a53a 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -61,7 +61,7 @@ class UserNotificationConfig { class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate { private let notificationCenter: UNUserNotificationCenter - private var notifications: [String: UNNotificationRequest] = [:] + private var notifications: Atomic<[String: UNNotificationRequest]> = Atomic([:]) override init() { self.notificationCenter = UNUserNotificationCenter.current() @@ -105,10 +105,9 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { sound: Preferences.Sound?, threadVariant: SessionThread.Variant, threadName: String, + applicationState: UIApplication.State, replacingIdentifier: String? ) { - AssertIsOnMainThread() - let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String) let content = UNMutableNotificationContent() content.categoryIdentifier = category.identifier @@ -119,16 +118,21 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { threadVariant == .community && replacingIdentifier == threadIdentifier ) - let isAppActive = UIApplication.shared.applicationState == .active if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: isAppActive) + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) } let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString) - let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil) + let isReplacingNotification: Bool = (notifications.wrappedValue[notificationIdentifier] != nil) + let shouldPresentNotification: Bool = shouldPresentNotification( + category: category, + applicationState: applicationState, + frontMostViewController: SessionApp.currentlyOpenConversationViewController.wrappedValue, + userInfo: userInfo + ) var trigger: UNNotificationTrigger? - if shouldPresentNotification(category: category, userInfo: userInfo) { + if shouldPresentNotification { if let displayableTitle = title?.filterForDisplay { content.title = displayableTitle } @@ -142,7 +146,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { repeats: false ) - let numberExistingNotifications: Int? = notifications[notificationIdentifier]? + let numberExistingNotifications: Int? = notifications.wrappedValue[notificationIdentifier]? .content .userInfo[AppNotificationUserInfoKey.threadNotificationCounter] .asType(Int.self) @@ -180,47 +184,48 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } notificationCenter.add(request) - notifications[notificationIdentifier] = request + notifications.mutate { $0[notificationIdentifier] = request } } func cancelNotifications(identifiers: [String]) { - AssertIsOnMainThread() - identifiers.forEach { notifications.removeValue(forKey: $0) } + notifications.mutate { notifications in + identifiers.forEach { notifications.removeValue(forKey: $0) } + } notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) } func cancelNotification(_ notification: UNNotificationRequest) { - AssertIsOnMainThread() cancelNotifications(identifiers: [notification.identifier]) } func cancelNotifications(threadId: String) { - AssertIsOnMainThread() - for notification in notifications.values { - guard let notificationThreadId = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String else { - continue + let notificationsIdsToCancel: [String] = notifications.wrappedValue + .values + .compactMap { notification in + guard + let notificationThreadId: String = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String, + notificationThreadId == threadId + else { return nil } + + return notification.identifier } - - guard notificationThreadId == threadId else { - continue - } - - cancelNotification(notification) - } + + cancelNotifications(identifiers: notificationsIdsToCancel) } func clearAllNotifications() { - AssertIsOnMainThread() notificationCenter.removeAllPendingNotificationRequests() notificationCenter.removeAllDeliveredNotifications() } - func shouldPresentNotification(category: AppNotificationCategory, userInfo: [AnyHashable: Any]) -> Bool { - AssertIsOnMainThread() - guard UIApplication.shared.applicationState == .active else { - return true - } + func shouldPresentNotification( + category: AppNotificationCategory, + applicationState: UIApplication.State, + frontMostViewController: UIViewController?, + userInfo: [AnyHashable: Any] + ) -> Bool { + guard applicationState == .active else { return true } guard category == .incomingMessage || category == .errorMessage else { return true @@ -231,7 +236,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { return true } - guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { + guard let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC else { return true } @@ -271,7 +276,8 @@ public class UserNotificationActionHandler: NSObject { AssertIsOnMainThread() assert(AppReadiness.isAppReady()) - let userInfo = response.notification.request.content.userInfo + let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo + let applicationState: UIApplication.State = UIApplication.shared.applicationState switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: @@ -307,7 +313,7 @@ public class UserNotificationActionHandler: NSObject { .eraseToAnyPublisher() } - return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText) + return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText, applicationState: applicationState) case .showThread: return actionHandler.showThread(userInfo: userInfo) diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 5f23d32d4..b09fae7ef 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -123,6 +123,25 @@ enum Onboarding { .eraseToAnyPublisher() } + enum State { + case newUser + case missingName + case completed + + static var current: State { + // If we have no identify information then the user needs to register + guard Identity.userExists() else { return .newUser } + + // If we have no display name then collect one (this can happen if the + // app crashed during onboarding which would leave the user in an invalid + // state with no display name) + guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else { return .missingName } + + // Otherwise we have enough for a full user and can start the app + return .completed + } + } + enum Flow { case register, recover, link diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 387f064bc..b413e0fc2 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -177,23 +177,10 @@ class HelpViewModel: SessionTableViewModel ())? = nil ) { - let version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - .defaulting(to: "") - #if DEBUG - let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" } - #else - let commitInfo: String? = nil - #endif - - let versionInfo: [String] = [ - "iOS \(UIDevice.current.systemVersion)", - "App: \(version)", - "libSession: \(SessionUtil.libSessionVersion)", - commitInfo - ].compactMap { $0 } - OWSLogger.info("[Version] \(versionInfo.joined(separator: ", "))") + OWSLogger.info("[Version] \(SessionApp.versionInfo)") DDLog.flushLog() let logFilePaths: [String] = AppEnvironment.shared.fileLogger.logFileManager.sortedLogFilePaths @@ -216,7 +203,7 @@ class HelpViewModel: SessionTableViewModel = Atomic([:]) - - - private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue static var isInitialized = false - // MARK: Tables + var countryNamesCache: Atomic<[String: String]> = Atomic([:]) + + // MARK: - Tables /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking @@ -58,13 +56,12 @@ final class IP2Country { } @objc func populateCacheIfNeededAsync() { - // This has to be sync since the `countryNamesCache` dict doesn't like async access - IP2Country.workQueue.sync { [weak self] in - _ = self?.populateCacheIfNeeded() + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.populateCacheIfNeeded() } } - func populateCacheIfNeeded() -> Bool { + @discardableResult func populateCacheIfNeeded() -> Bool { guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false } countryNamesCache.mutate { [weak self] cache in diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 6497f7e3e..cd85c095b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -46,6 +46,10 @@ extension MessageReceiver { private static func handleNewCallMessage(_ db: Database, message: CallMessage) throws { SNLog("[Calls] Received pre-offer message.") + // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid + // requiring main-thread execution + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) + // It is enough just ignoring the pre offers, other call messages // for this call would be dropped because of no Session call instance guard @@ -69,7 +73,8 @@ extension MessageReceiver { .notifyUser( db, forIncomingCall: interaction, - in: thread + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) } } @@ -86,7 +91,8 @@ extension MessageReceiver { .notifyUser( db, forIncomingCall: interaction, - in: thread + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 2958b3548..34ce81e73 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -331,7 +331,8 @@ extension MessageReceiver { .notifyUser( db, for: interaction, - in: thread + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) return interactionId @@ -372,6 +373,9 @@ extension MessageReceiver { switch reaction.kind { case .react: + // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid + // requiring main-thread execution + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let reaction: Reaction = try Reaction( @@ -398,7 +402,8 @@ extension MessageReceiver { .notifyUser( db, forReaction: reaction, - in: thread + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) } case .remove: diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift index 51e2951ac..f946eb476 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -4,9 +4,9 @@ import Foundation import GRDB public protocol NotificationsProtocol { - func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) - func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) - func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) + func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) + func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) + func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) func cancelNotifications(identifiers: [String]) func clearAllNotifications() } diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index ff0d470b8..11ec81bc0 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -24,7 +24,7 @@ public class DeviceSleepManager: NSObject { return "SleepBlock(\(String(reflecting: blockObject)))" } - init(blockObject: NSObject) { + init(blockObject: NSObject?) { self.blockObject = blockObject } } @@ -51,14 +51,14 @@ public class DeviceSleepManager: NSObject { } @objc - public func addBlock(blockObject: NSObject) { + public func addBlock(blockObject: NSObject?) { blocks.append(SleepBlock(blockObject: blockObject)) ensureSleepBlocking() } @objc - public func removeBlock(blockObject: NSObject) { + public func removeBlock(blockObject: NSObject?) { blocks = blocks.filter { $0.blockObject != nil && $0.blockObject != blockObject } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 9c625f9b6..acff494bb 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -9,7 +9,7 @@ import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -124,7 +124,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } guard @@ -180,7 +180,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // No reaction notifications for muted, group threads or message requests diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 3a943047e..59b31366b 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -158,7 +158,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .notifyUser( db, forIncomingCall: interaction, - in: thread + in: thread, + applicationState: .background ) } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 4013936b7..2facdea62 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -10,7 +10,9 @@ import SessionMessagingKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate { private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel() - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var hasLoadedInitialData: Bool = false var shareNavController: ShareNavController? @@ -79,8 +81,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -91,8 +92,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: Layout @@ -104,6 +104,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // MARK: - Updating private func startObservingChanges() { + guard dataChangeObservable == nil else { return } + // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableViewData, @@ -115,6 +117,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } + private func stopObservingChanges() { + dataChangeObservable = nil + } + private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index ce25d5b86..4cd35ccce 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -361,10 +361,26 @@ open class Storage { // MARK: - Functions + private static func logIfNeeded(_ error: Error, isWrite: Bool) { + switch error { + case DatabaseError.SQLITE_ABORT: + let message: String = ((error as? DatabaseError)?.message ?? "Unknown") + SNLog("[Storage] Database \(isWrite ? "write" : "read") failed due to error: \(message)") + + default: break + } + } + + private static func logIfNeeded(_ error: Error, isWrite: Bool) -> T? { + logIfNeeded(error, isWrite: isWrite) + return nil + } + @discardableResult public final func write(updates: (Database) throws -> T?) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbWriter.write(updates) + do { return try dbWriter.write(updates) } + catch { return Storage.logIfNeeded(error, isWrite: true) } } open func writeAsync(updates: @escaping (Database) throws -> T) { @@ -377,6 +393,11 @@ open class Storage { dbWriter.asyncWrite( updates, completion: { db, result in + switch result { + case .failure(let error): Storage.logIfNeeded(error, isWrite: true) + default: break + } + try? completion(db, result) } ) @@ -400,7 +421,10 @@ open class Storage { return Deferred { Future { resolver in do { resolver(Result.success(try dbWriter.write(updates))) } - catch { resolver(Result.failure(error)) } + catch { + Storage.logIfNeeded(error, isWrite: true) + resolver(Result.failure(error)) + } } }.eraseToAnyPublisher() } @@ -423,7 +447,10 @@ open class Storage { return Deferred { Future { resolver in do { resolver(Result.success(try dbWriter.read(value))) } - catch { resolver(Result.failure(error)) } + catch { + Storage.logIfNeeded(error, isWrite: false) + resolver(Result.failure(error)) + } } }.eraseToAnyPublisher() } @@ -431,7 +458,8 @@ open class Storage { @discardableResult public final func read(_ value: (Database) throws -> T?) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbWriter.read(value) + do { return try dbWriter.read(value) } + catch { return Storage.logIfNeeded(error, isWrite: false) } } /// Rever to the `ValueObservation.start` method for full documentation diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 9e89ed175..efc6d1c25 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -90,12 +90,10 @@ public enum AppSetup { // method when calling within a database read/write closure) Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) } - DispatchQueue.main.async { - migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) - - // The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } - } + migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } } ) } diff --git a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index 775319890..a832873b2 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -8,15 +8,15 @@ import SignalCoreKit public class NoopNotificationsManager: NotificationsProtocol { public init() {} - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } From 6cf7cc42abcea4ae15e9528efd3821135e144f9b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 28 Jun 2023 18:03:40 +1000 Subject: [PATCH 2/3] Fixed up the remaining reported internal testing issues Removed the 'readPublisherFlatMap/writePublisherFlatMap' functions as they easily resulted in behaviours which held up database threads Tweaked the logic around starting the open group pollers to avoid an unlikely atomic lock blocks Updated some logic to avoid accessing database read threads for longer than needed Updated the OpenGroupManager to only update the 'seqNo' value for valid messages Cleaned up some double Atomic wrapped instances which had some weird access behaviours Fixed an issue where a database read thread could have been started within a database write thread Fixed an issue where the ReplaySubject might not emit values in some cases --- Session.xcodeproj/project.pbxproj | 12 +- .../Calls/Call Management/SessionCall.swift | 16 +- .../Views & Modals/IncomingCallBanner.swift | 2 +- Session/Closed Groups/EditClosedGroupVC.swift | 25 +- Session/Closed Groups/NewClosedGroupVC.swift | 6 +- .../ConversationVC+Interaction.swift | 22 +- .../Conversations/ConversationViewModel.swift | 9 +- Session/Home/HomeVC.swift | 10 +- Session/Home/HomeViewModel.swift | 37 +- Session/Meta/AppDelegate.swift | 26 +- .../Translations/de.lproj/Localizable.strings | 4 +- .../Translations/en.lproj/Localizable.strings | 4 +- .../Translations/es.lproj/Localizable.strings | 4 +- .../Translations/fa.lproj/Localizable.strings | 4 +- .../Translations/fi.lproj/Localizable.strings | 4 +- .../Translations/fr.lproj/Localizable.strings | 4 +- .../Translations/hi.lproj/Localizable.strings | 4 +- .../Translations/hr.lproj/Localizable.strings | 4 +- .../id-ID.lproj/Localizable.strings | 4 +- .../Translations/it.lproj/Localizable.strings | 4 +- .../Translations/ja.lproj/Localizable.strings | 4 +- .../Translations/nl.lproj/Localizable.strings | 4 +- .../Translations/pl.lproj/Localizable.strings | 4 +- .../pt_BR.lproj/Localizable.strings | 4 +- .../Translations/ru.lproj/Localizable.strings | 4 +- .../Translations/si.lproj/Localizable.strings | 4 +- .../Translations/sk.lproj/Localizable.strings | 4 +- .../Translations/sv.lproj/Localizable.strings | 4 +- .../Translations/th.lproj/Localizable.strings | 4 +- .../vi-VN.lproj/Localizable.strings | 4 +- .../zh-Hant.lproj/Localizable.strings | 4 +- .../zh_CN.lproj/Localizable.strings | 4 +- Session/Open Groups/JoinOpenGroupVC.swift | 11 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 39 +- Session/Settings/NukeDataModal.swift | 7 +- SessionMessagingKit/Calls/WebRTCSession.swift | 8 +- .../Database/Models/Profile.swift | 12 - .../Open Groups/OpenGroupManager.swift | 438 ++++++------ .../Open Groups/Types/OpenGroupAPIError.swift | 2 + SessionMessagingKit/SMKDependencies.swift | 2 +- ...essageReceiver+ConfigurationMessages.swift | 17 +- .../MessageSender+ClosedGroups.swift | 626 +++++++++--------- .../Pollers/OpenGroupPoller.swift | 63 +- .../SessionUtil+UserGroups.swift | 17 +- .../SessionUtil/SessionUtil.swift | 49 +- .../Utilities/ProfileManager.swift | 2 +- .../Open Groups/OpenGroupManagerSpec.swift | 4 +- .../_TestUtilities/MockOGMCache.swift | 6 +- SessionSnodeKit/SSKDependencies.swift | 2 +- .../Combine/ReplaySubject.swift | 23 +- SessionUtilitiesKit/Database/Storage.swift | 18 - .../General/Dependencies.swift | 41 +- SessionUtilitiesKit/General/General.swift | 31 +- 53 files changed, 906 insertions(+), 765 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 211bfa740..16cef78c5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -6417,7 +6417,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6489,7 +6489,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6554,7 +6554,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6628,7 +6628,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -7536,7 +7536,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7607,7 +7607,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 410; + CURRENT_PROJECT_VERSION = 411; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 070545ef9..53b392657 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -246,11 +246,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { ) // Start the timeout timer for the call .handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() }) - .flatMap { _ in - Storage.shared.writePublisherFlatMap { db -> AnyPublisher in - webRTCSession.sendOffer(db, to: sessionId) - } - } + .flatMap { _ in webRTCSession.sendOffer(to: thread) } .sinkUntilComplete() } @@ -431,10 +427,12 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let sessionId: String = self.sessionId let webRTCSession: WebRTCSession = self.webRTCSession - Storage.shared - .readPublisherFlatMap { db in - webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) - } + guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else { + return + } + + webRTCSession + .sendOffer(to: thread, isRestartingICEConnection: true) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index b6e7ba8b9..98fdbf424 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -114,7 +114,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { publicKey: call.sessionId, threadVariant: .contact, customImageData: nil, - profile: Profile.fetchOrCreate(id: call.sessionId), + profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) }, additionalProfile: nil ) displayNameLabel.text = call.contactName diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 88f87fbc9..11161e534 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -464,21 +464,18 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in Storage.shared - .writePublisherFlatMap { db -> AnyPublisher in - if !updatedMemberIds.contains(userPublicKey) { - try MessageSender.leave( - db, - groupPublicKey: threadId, - deleteThread: true - ) - - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return MessageSender.update( + .writePublisher { db in + guard updatedMemberIds.contains(userPublicKey) else { return } + + try MessageSender.leave( db, + groupPublicKey: threadId, + deleteThread: true + ) + + } + .flatMap { + MessageSender.update( groupPublicKey: threadId, with: updatedMemberIds, name: updatedName diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 3970e52c2..deb2165f7 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -332,10 +332,8 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - Storage.shared - .writePublisherFlatMap { db in - try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) - } + MessageSender + .createClosedGroup(name: name, members: selectedContacts) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 504067efd..c3073d9ce 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1181,7 +1181,12 @@ extension ConversationVC: ) } - func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { + func react( + _ cellViewModel: MessageViewModel, + with emoji: String, + remove: Bool, + using dependencies: Dependencies = Dependencies() + ) { guard self.viewModel.threadData.threadIsMessageRequest != true && ( cellViewModel.variant == .standardIncoming || @@ -1193,7 +1198,7 @@ extension ConversationVC: let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() - let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps + let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps guard recentReactionTimestamps.count < 20 || @@ -1211,7 +1216,7 @@ extension ConversationVC: return } - General.cache.mutate { + dependencies.mutableGeneralCache.mutate { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps .suffix(19)) .appending(sentTimestamp) @@ -1522,7 +1527,7 @@ extension ConversationVC: } Storage.shared - .writePublisherFlatMap { db in + .writePublisher { db in OpenGroupManager.shared.add( db, roomToken: room, @@ -1531,6 +1536,15 @@ extension ConversationVC: calledFromConfigHandling: false ) } + .flatMap { successfullyAddedGroup in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: room, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) + } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index a5dcf5086..cd9c8b8a5 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -63,6 +63,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) { typealias InitialData = ( + currentUserPublicKey: String, initialUnreadInteractionInfo: Interaction.TimestampInfo?, threadIsBlocked: Bool, currentUserIsClosedGroupMember: Bool?, @@ -73,6 +74,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let initialData: InitialData? = Storage.shared.read { db -> InitialData in let interaction: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) // If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest // unread interaction and start focused around that one @@ -94,7 +96,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil : GroupMember .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db)) + .filter(groupMember[.profileId] == currentUserPublicKey) .filter(groupMember[.role] == GroupMember.Role.standard) .isNotEmpty(db) ) @@ -112,6 +114,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) return ( + currentUserPublicKey, initialUnreadInteractionInfo, threadIsBlocked, currentUserIsClosedGroupMember, @@ -128,7 +131,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.threadData = SessionThreadViewModel( threadId: threadId, threadVariant: threadVariant, - threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()), + threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId), threadIsBlocked: initialData?.threadIsBlocked, currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, openGroupPermissions: initialData?.openGroupPermissions @@ -141,7 +144,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // distinct stutter) self.pagedDataObserver = self.setupPagedObserver( for: threadId, - userPublicKey: getUserHexEncodedPublicKey(), + userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()), blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey( threadId: threadId, threadVariant: threadVariant diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index d62e359f0..5ddb4823c 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -228,7 +228,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData // Preparation SessionApp.homeViewController.mutate { $0 = self } - updateNavBarButtons() + updateNavBarButtons(userProfile: self.viewModel.state.userProfile) setUpNavBarSessionHeading() // Recovery phrase reminder @@ -382,7 +382,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData } if updatedState.userProfile != self.viewModel.state.userProfile { - updateNavBarButtons() + updateNavBarButtons(userProfile: updatedState.userProfile) } // Update the 'view seed' UI @@ -489,17 +489,17 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData } } - private func updateNavBarButtons() { + private func updateNavBarButtons(userProfile: Profile) { // Profile picture view let profilePictureView = ProfilePictureView(size: .navigation) profilePictureView.accessibilityIdentifier = "User settings" profilePictureView.accessibilityLabel = "User settings" profilePictureView.isAccessibilityElement = true profilePictureView.update( - publicKey: getUserHexEncodedPublicKey(), + publicKey: userProfile.id, threadVariant: .contact, customImageData: nil, - profile: Profile.fetchOrCreateCurrentUser(), + profile: userProfile, additionalProfile: nil ) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 675317605..f081d5594 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -26,32 +26,39 @@ public class HomeViewModel { let showViewedSeedBanner: Bool let hasHiddenMessageRequests: Bool let unreadMessageRequestThreadCount: Int - let userProfile: Profile? - - init( - showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed], - hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests], - unreadMessageRequestThreadCount: Int = 0, - userProfile: Profile? = nil - ) { - self.showViewedSeedBanner = showViewedSeedBanner - self.hasHiddenMessageRequests = hasHiddenMessageRequests - self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount - self.userProfile = userProfile - } + let userProfile: Profile } // MARK: - Initialization init() { - self.state = State() + typealias InitialData = ( + showViewedSeedBanner: Bool, + hasHiddenMessageRequests: Bool, + profile: Profile + ) + + let initialData: InitialData? = Storage.shared.read { db -> InitialData in + ( + !db[.hasViewedSeed], + db[.hasHiddenMessageRequests], + Profile.fetchOrCreateCurrentUser(db) + ) + } + + self.state = State( + showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true), + hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false), + unreadMessageRequestThreadCount: 0, + userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser()) + ) self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we // also want to skip the initial query and trigger it async so that the push animation // doesn't stutter (it should load basically immediately but without this there is a // distinct stutter) - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = self.state.userProfile.id let thread: TypedTableAlias = TypedTableAlias() self.pagedDataObserver = PagedDatabaseObserver( pagedTable: SessionThread.self, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index ae7592fef..21563c96b 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -13,7 +13,7 @@ import SignalCoreKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 5 + private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10 var window: UIWindow? var backgroundSnapshotBlockerWindow: UIWindow? @@ -73,7 +73,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .migrationError(error)) + DispatchQueue.main.async { + self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error)) + } return } @@ -150,7 +152,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { DispatchQueue.main.async { - self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .migrationError(error)) + self?.showFailedStartupAlert(calledFrom: .enterForeground, error: .databaseError(error)) } return } @@ -372,8 +374,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }) switch error { + // Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to + // resolve it (most likely the database is locked or the key was somehow lost - safer to get them + // to restart and manually reinstall/restore) + case .databaseError(StorageError.startupFailed): break + // Offer the 'Restore' option if it was a migration error - case .migrationError: + case .databaseError: alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in if SUKLegacy.hasLegacyDatabaseFile { // Remove the legacy database and any message hashes that have been migrated to the new DB @@ -402,7 +409,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD migrationsCompletion: { [weak self] result, needsConfigSync in switch result { case .failure: - self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore) + DispatchQueue.main.async { + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore) + } case .success: self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) @@ -848,16 +857,15 @@ private enum LifecycleMethod { // MARK: - StartupError private enum StartupError: Error { - case databaseStartupError - case migrationError(Error) + case databaseError(Error) case failedToRestore case startupTimeout var message: String { switch self { - case .databaseStartupError: return "DATABASE_STARTUP_FAILED".localized() + case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized() case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized() - case .migrationError: return "DATABASE_MIGRATION_FAILED".localized() + case .databaseError: return "DATABASE_MIGRATION_FAILED".localized() case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized() } } diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 688070b13..36e57b643 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index 8f0e8f077..fb8c06328 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 81652e295..26cd6fcc7 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 9ca92b73c..591ed7146 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید"; "LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود."; "RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; "RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index c63940d0a..d583693df 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 1b1005b26..562da051b 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; "LOADING_CONVERSATIONS" = "Chargement des conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; "RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; "RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index 534ff51ef..c5837e958 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 39506e93f..60b5da861 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index a19d65df2..8a45a00b0 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 101c5112a..547d49c04 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 6de3a1708..e380f3ea7 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 3f071ad99..8aff0e69e 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index dd56d5cd4..bc5252c48 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index bdd6a7f9a..16a6b845d 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index bfaf6bb8c..a5968d207 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index c35173c5b..d0f8738c0 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 8e5f5db93..8df3501ac 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index dfdb07bd1..1d3d86dff 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 0431a2c30..581fc3e1c 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index cbe4e9604..07311c2ab 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 2c1c6c1d0..8fd5c3e27 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index d69d20a71..7f63ffbf9 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -414,10 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; "APP_STARTUP_EXIT" = "Exit"; -"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 3f25f1175..b5f214cc9 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -169,7 +169,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in Storage.shared - .writePublisherFlatMap { db in + .writePublisher { db in OpenGroupManager.shared.add( db, roomToken: roomToken, @@ -178,6 +178,15 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC calledFromConfigHandling: false ) } + .flatMap { successfullyAddedGroup in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: roomToken, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) + } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 9fb666989..f5e4718aa 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -9,7 +9,7 @@ import SessionUIKit final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2) private var maxWidth: CGFloat - private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } } + private var data: [OpenGroupManager.DefaultRoomInfo] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? @@ -146,8 +146,13 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle .subscribe(on: DispatchQueue.global(qos: .default)) .receive(on: DispatchQueue.main, immediatelyIfMain: true) .sinkUntilComplete( - receiveCompletion: { [weak self] _ in self?.update() }, - receiveValue: { [weak self] rooms in self?.rooms = rooms } + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: self?.update() + } + }, + receiveValue: { [weak self] roomInfo in self?.data = roomInfo } ) } @@ -157,7 +162,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle spinner.stopAnimating() spinner.isHidden = true - let roomCount: CGFloat = CGFloat(min(rooms.count, 8)) // Cap to a maximum of 8 (4 rows of 2) + let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing)) heightConstraint.constant = height @@ -184,7 +189,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // If there isn't an even number of items then we want to calculate proper sizing return CGSize( - width: Cell.calculatedWith(for: rooms[indexPath.item].name), + width: Cell.calculatedWith(for: data[indexPath.item].room.name), height: OpenGroupSuggestionGrid.cellHeight ) } @@ -192,12 +197,12 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Data Source func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return min(rooms.count, 8) // Cap to a maximum of 8 (4 rows of 2) + return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2) } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath) - cell.room = rooms[indexPath.item] + cell.update(with: data[indexPath.item].room, existingImageData: data[indexPath.item].existingImageData) return cell } @@ -205,7 +210,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Interaction func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let room = rooms[indexPath.section * itemsPerSection + indexPath.item] + let room = data[indexPath.section * itemsPerSection + indexPath.item].room delegate?.join(room) } } @@ -232,8 +237,6 @@ extension OpenGroupSuggestionGrid { ) } - var room: OpenGroupAPI.Room? { didSet { update() } } - private lazy var snContentView: UIView = { let result: UIView = UIView() result.themeBorderColor = .borderSeparator @@ -307,9 +310,7 @@ extension OpenGroupSuggestionGrid { snContentView.pin(to: self) } - private func update() { - guard let room: OpenGroupAPI.Room = room else { return } - + fileprivate func update(with room: OpenGroupAPI.Room, existingImageData: Data?) { label.text = room.name // Only continue if we have a room image @@ -322,11 +323,13 @@ extension OpenGroupSuggestionGrid { Publishers .MergeMany( - Storage.shared - .readPublisherFlatMap { db in - OpenGroupManager - .roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer) - } + OpenGroupManager + .roomImage( + fileId: imageId, + for: room.token, + on: OpenGroupAPI.defaultServer, + existingData: existingImageData + ) .map { ($0, true) } .eraseToAnyPublisher(), // If we have already received the room image then the above will emit first and diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index eafc62330..bda18bf9c 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -220,7 +220,7 @@ final class NukeDataModal: Modal { } } - private func deleteAllLocalData() { + private func deleteAllLocalData(using dependencies: Dependencies = Dependencies()) { // Unregister push notifications if needed let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken] @@ -244,7 +244,10 @@ final class NukeDataModal: Modal { UserDefaults.removeAll() // Remove the cached key so it gets re-cached on next access - General.cache.mutate { $0.encodedPublicKey = nil } + dependencies.mutableGeneralCache.mutate { + $0.encodedPublicKey = nil + $0.recentReactionTimestamps = [] + } // Clear the Snode pool SnodeAPI.clearSnodePool() diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 7d7added5..71e098ae2 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -146,19 +146,13 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } public func sendOffer( - _ db: Database, - to sessionId: String, + to thread: SessionThread, isRestartingICEConnection: Bool = false ) -> AnyPublisher { SNLog("[Calls] Sending offer message.") let uuid: String = self.uuid let mediaConstraints: RTCMediaConstraints = mediaConstraints(isRestartingICEConnection) - guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { - return Fail(error: WebRTCSessionError.noThread) - .eraseToAnyPublisher() - } - return Deferred { Future { [weak self] resolver in self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 10f57c059..08761ada9 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -265,18 +265,6 @@ public extension Profile { ) } - /// Fetches or creates a Profile for the specified user - /// - /// **Note:** This method intentionally does **not** save the newly created Profile, - /// it will need to be explicitly saved after calling - static func fetchOrCreate(id: String) -> Profile { - let exisingProfile: Profile? = Storage.shared.read { db in - try Profile.fetchOne(db, id: id) - } - - return (exisingProfile ?? defaultFor(id)) - } - /// Fetches or creates a Profile for the specified user /// /// **Note:** This method intentionally does **not** save the newly created Profile, diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index fee25ffba..c79eecf07 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -7,30 +7,15 @@ import Sodium import SessionUtilitiesKit import SessionSnodeKit -// MARK: - OGMCacheType - -public protocol OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? { get set } - var groupImagePublishers: [String: AnyPublisher] { get set } - - var pollers: [String: OpenGroupAPI.Poller] { get set } - var isPolling: Bool { get set } - - var hasPerformedInitialPoll: [String: Bool] { get set } - var timeSinceLastPoll: [String: TimeInterval] { get set } - - var pendingChanges: [OpenGroupAPI.PendingChange] { get set } - - func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval -} - // MARK: - OpenGroupManager public final class OpenGroupManager { + public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?) + // MARK: - Cache - public class Cache: OGMCacheType { - public var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? + public class Cache: OGMMutableCacheType { + public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? public var groupImagePublishers: [String: AnyPublisher] = [:] public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server @@ -60,10 +45,7 @@ public final class OpenGroupManager { // MARK: - Variables - public static let shared: OpenGroupManager = OpenGroupManager() - - /// Note: This should not be accessed directly but rather via the 'OGMDependencies' type - fileprivate let mutableCache: Atomic = Atomic(Cache()) + public static let shared: OpenGroupManager = OpenGroupManager() // MARK: - Polling @@ -87,6 +69,7 @@ public final class OpenGroupManager { } .defaulting(to: []) + // Update the cache state and re-create all of the pollers dependencies.mutableCache.mutate { cache in cache.isPolling = true cache.pollers = servers @@ -94,14 +77,9 @@ public final class OpenGroupManager { 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) } } + // Now that the pollers have been created actually start them + dependencies.cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } } } @@ -185,7 +163,7 @@ public final class OpenGroupManager { } // First check if there is no poller for the specified server - if serverOptions.first(where: { dependencies.cache.pollers[$0] != nil }) == nil { + if Set(dependencies.cache.pollers.keys).intersection(serverOptions).isEmpty { return false } @@ -209,13 +187,11 @@ public final class OpenGroupManager { publicKey: String, calledFromConfigHandling: Bool, dependencies: OGMDependencies = OGMDependencies() - ) -> AnyPublisher { + ) -> Bool { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)") - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return false } // Store the open group information @@ -270,72 +246,86 @@ public final class OpenGroupManager { ) } - /// We want to avoid blocking the db write thread so we return a future which resolves once the db transaction completes - /// and dispatches the result to another queue, this means that the caller can respond to errors resulting from attepting to - /// join the community - return Future { resolver in - db.afterNextTransactionNested { _ in - OpenGroupAPI.workQueue.async { - resolver(Result.success(())) - } + return true + } + + public func performInitialRequestsAfterAdd( + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String, + calledFromConfigHandling: Bool, + dependencies: OGMDependencies = OGMDependencies() + ) -> AnyPublisher { + guard successfullyAddedGroup else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // Store the open group information + let targetServer: String = { + guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { + return server.lowercased() } - } - .flatMap { _ in - dependencies.storage - .readPublisher { db in - try OpenGroupAPI - .preparedCapabilitiesAndRoom( - db, - for: roomToken, - on: targetServer, - using: dependencies - ) - } - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .flatMap { info, response -> Future in - Future { resolver in - dependencies.storage.write { db in - // Add the new open group to libSession - if !calledFromConfigHandling { - try SessionUtil.add( - db, - server: server, - rootToken: roomToken, - publicKey: publicKey - ) - } - - // Store the capabilities first - OpenGroupManager.handleCapabilities( + + return OpenGroupAPI.defaultServer + }() + + return dependencies.storage + .readPublisher { db in + try OpenGroupAPI + .preparedCapabilitiesAndRoom( db, - capabilities: response.capabilities.data, - on: targetServer - ) - - // Then the room - try OpenGroupManager.handlePollInfo( - db, - pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), - publicKey: publicKey, for: roomToken, on: targetServer, - dependencies: dependencies - ) { - resolver(Result.success(())) + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { info, response -> Future in + Future { resolver in + dependencies.storage.write { db in + // Add the new open group to libSession + if !calledFromConfigHandling { + try SessionUtil.add( + db, + server: server, + rootToken: roomToken, + publicKey: publicKey + ) + } + + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.capabilities.data, + on: targetServer + ) + + // Then the room + try OpenGroupManager.handlePollInfo( + db, + pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), + publicKey: publicKey, + for: roomToken, + on: targetServer, + dependencies: dependencies + ) { + resolver(Result.success(())) + } } } } - } - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Failed to join open group.") + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Failed to join open group.") + } } - } - ) - .eraseToAnyPublisher() + ) + .eraseToAnyPublisher() } public func delete( @@ -534,9 +524,11 @@ public final class OpenGroupManager { // Start the poller if needed if dependencies.cache.pollers[server.lowercased()] == nil { dependencies.mutableCache.mutate { + $0.pollers[server.lowercased()]?.stop() $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) - $0.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) } + + dependencies.cache.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) } /// Start downloading the room image (if we don't have one or it's been updated) @@ -549,10 +541,10 @@ public final class OpenGroupManager { { OpenGroupManager .roomImage( - db, fileId: imageId, for: roomToken, on: server, + existingData: openGroup.imageData, using: dependencies ) // Note: We need to subscribe and receive on different threads to ensure the @@ -593,45 +585,26 @@ public final class OpenGroupManager { on server: String, dependencies: OGMDependencies = OGMDependencies() ) { - // Sorting the messages by server ID before importing them fixes an issue where messages - // that quote older messages can't find those older messages guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { SNLog("Couldn't handle open group messages.") return } + // Sorting the messages by server ID before importing them fixes an issue where messages + // that quote older messages can't find those older messages let sortedMessages: [OpenGroupAPI.Message] = messages .filter { $0.deleted != true } .sorted { lhs, rhs in lhs.id < rhs.id } var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages .filter { $0.deleted == true } .map { ($0.id, $0.seqNo) } - let updateSeqNo: (Database, String, inout Int64, Int64, OGMDependencies) -> () = { db, openGroupId, lastValidSeqNo, seqNo, dependencies in - // Only update the data if the 'seqNo' is larger than the lastValidSeqNo (only want it to increase) - guard seqNo > lastValidSeqNo else { return } - - // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') - _ = try? OpenGroup - .filter(id: openGroupId) - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: seqNo)) - - // Update pendingChange cache - dependencies.mutableCache.mutate { - $0.pendingChanges = $0.pendingChanges - .filter { $0.seqNo == nil || $0.seqNo! > seqNo } - } - - // Update the inout value - lastValidSeqNo = seqNo - } + var largestValidSeqNo: Int64 = openGroup.sequenceNumber // Process the messages - var lastValidSeqNo: Int64 = -1 sortedMessages.forEach { message in if message.base64EncodedData == nil && message.reactions == nil { messageServerInfoToRemove.append((message.id, message.seqNo)) - - return updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies) + return } // Handle messages @@ -658,7 +631,7 @@ public final class OpenGroupManager { associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), dependencies: dependencies ) - updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } } catch { @@ -703,7 +676,7 @@ public final class OpenGroupManager { openGroupMessageServerId: message.id, openGroupReactions: reactions ) - updateSeqNo(db, openGroup.id, &lastValidSeqNo, message.seqNo, dependencies) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } catch { SNLog("Couldn't handle open group reactions due to error: \(error).") @@ -712,17 +685,27 @@ public final class OpenGroupManager { } // Handle any deletions that are needed - guard !messageServerInfoToRemove.isEmpty else { return } + if !messageServerInfoToRemove.isEmpty { + let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id } + _ = try? Interaction + .filter(Interaction.Columns.threadId == openGroup.threadId) + .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) + .deleteAll(db) + + // Update the seqNo for deletions + largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0)) + } - let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id } - _ = try? Interaction - .filter(Interaction.Columns.threadId == openGroup.threadId) - .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) - .deleteAll(db) - - // Update the seqNo for deletions - if let lastDeletionSeqNo: Int64 = messageServerInfoToRemove.map({ $0.seqNo }).max() { - updateSeqNo(db, openGroup.id, &lastValidSeqNo, lastDeletionSeqNo, dependencies) + // Now that we've finished processing all valid message changes we can update the `sequenceNumber` to + // the `largestValidSeqNo` value + _ = try? OpenGroup + .filter(id: openGroup.id) + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) + + // Update pendingChange cache based on the `largestValidSeqNo` value + dependencies.mutableCache.mutate { + $0.pendingChanges = $0.pendingChanges + .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } } } @@ -1013,14 +996,14 @@ public final class OpenGroupManager { subscribeQueue: OpenGroupAPI.workQueue, receiveQueue: OpenGroupAPI.workQueue ) - ) -> AnyPublisher<[OpenGroupAPI.Room], Error> { + ) -> AnyPublisher<[DefaultRoomInfo], Error> { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher { + if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher { return existingPublisher } // Try to retrieve the default rooms 8 times - let publisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.storage + let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.storage .readPublisher { db in try OpenGroupAPI.preparedCapabilitiesAndRooms( db, @@ -1032,8 +1015,8 @@ public final class OpenGroupManager { .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .retry(8) - .map { info, response in - dependencies.storage.writeAsync { db in + .map { info, response -> [DefaultRoomInfo]? in + dependencies.storage.write { db -> [DefaultRoomInfo] in // Store the capabilities first OpenGroupManager.handleCapabilities( db, @@ -1042,8 +1025,8 @@ public final class OpenGroupManager { ) // Then the rooms - response.rooms.data - .compactMap { room -> (String, String)? in + return response.rooms.data + .map { room -> DefaultRoomInfo in // Try to insert an inactive version of the OpenGroup (use 'insert' // rather than 'save' as we want it to fail if the room already exists) do { @@ -1066,24 +1049,32 @@ public final class OpenGroupManager { } catch {} - guard let imageId: String = room.imageId else { return nil } + // Retrieve existing image data if we have it + let existingImageData: Data? = try? OpenGroup + .select(.imageData) + .filter(id: OpenGroup.idFor(roomToken: room.token, server: OpenGroupAPI.defaultServer)) + .asRequest(of: Data.self) + .fetchOne(db) - return (imageId, room.token) - } - .forEach { imageId, roomToken in - roomImage( - db, - fileId: imageId, - for: roomToken, - on: OpenGroupAPI.defaultServer, - using: dependencies - ) + return (room, existingImageData) } } - - return response.rooms.data } + .map { ($0 ?? []) } .handleEvents( + receiveOutput: { roomInfo in + roomInfo.forEach { room, existingImageData in + guard let imageId: String = room.imageId else { return } + + roomImage( + fileId: imageId, + for: room.token, + on: OpenGroupAPI.defaultServer, + existingData: existingImageData, + using: dependencies + ) + } + }, receiveCompletion: { result in switch result { case .finished: break @@ -1108,10 +1099,10 @@ public final class OpenGroupManager { } @discardableResult public static func roomImage( - _ db: Database, fileId: String, for roomToken: String, on server: String, + existingData: Data?, using dependencies: OGMDependencies = OGMDependencies( subscribeQueue: .global(qos: .background) ) @@ -1130,16 +1121,12 @@ public final class OpenGroupManager { let now: Date = dependencies.date let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) + let canUseExistingImage: Bool = ( + server.lowercased() == OpenGroupAPI.defaultServer && + timeSinceLastUpdate < updateInterval + ) - if - server.lowercased() == OpenGroupAPI.defaultServer, - timeSinceLastUpdate < updateInterval, - let data = try? OpenGroup - .select(.imageData) - .filter(id: threadId) - .asRequest(of: Data.self) - .fetchOne(db) - { + if canUseExistingImage, let data: Data = existingData { return Just(data) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -1149,33 +1136,54 @@ public final class OpenGroupManager { return publisher } - let sendData: OpenGroupAPI.PreparedSendData - - do { - sendData = try OpenGroupAPI - .preparedDownloadFile( - db, - fileId: fileId, - from: roomToken, - on: server, - using: dependencies - ) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - // Defer the actual download and run it on a separate thread to avoid blocking the calling thread let publisher: AnyPublisher = Deferred { Future { resolver in dependencies.subscribeQueue.async { // Hold on to the publisher until it has completed at least once - OpenGroupAPI - .send( - data: sendData, - using: dependencies - ) + dependencies.storage + .readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData?) in + if canUseExistingImage { + let maybeExistingData: Data? = try? OpenGroup + .select(.imageData) + .filter(id: threadId) + .asRequest(of: Data.self) + .fetchOne(db) + + if let existingData: Data = maybeExistingData { + return (existingData, nil) + } + } + + return ( + nil, + try OpenGroupAPI + .preparedDownloadFile( + db, + fileId: fileId, + from: roomToken, + on: server, + using: dependencies + ) + ) + } + .flatMap { info in + switch info { + case (.some(let existingData), _): + return Just(existingData) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + case (_, .some(let sendData)): + return OpenGroupAPI.send(data: sendData, using: dependencies) + .map { _, imageData in imageData } + .eraseToAnyPublisher() + + default: + return Fail(error: HTTPError.generic) + .eraseToAnyPublisher() + } + } .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -1183,7 +1191,7 @@ public final class OpenGroupManager { case .failure(let error): resolver(Result.failure(error)) } }, - receiveValue: { _, imageData in + receiveValue: { imageData in if server.lowercased() == OpenGroupAPI.defaultServer { dependencies.storage.write { db in _ = try OpenGroup @@ -1216,25 +1224,73 @@ public final class OpenGroupManager { } } +// MARK: - OGMCacheType + +public protocol OGMMutableCacheType: OGMCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set } + var groupImagePublishers: [String: AnyPublisher] { get set } + + var pollers: [String: OpenGroupAPI.Poller] { get set } + var isPolling: Bool { get set } + + var hasPerformedInitialPoll: [String: Bool] { get set } + var timeSinceLastPoll: [String: TimeInterval] { get set } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get set } + + func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval +} + +/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol OGMCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } + var groupImagePublishers: [String: AnyPublisher] { get } + + var pollers: [String: OpenGroupAPI.Poller] { get } + var isPolling: Bool { get } + + var hasPerformedInitialPoll: [String: Bool] { get } + var timeSinceLastPoll: [String: TimeInterval] { get } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get } +} // MARK: - OGMDependencies extension OpenGroupManager { public class OGMDependencies: SMKDependencies { - internal var _mutableCache: Atomic?> - public var mutableCache: Atomic { - get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } } - set { _mutableCache.mutate { $0 = newValue } } - } + /// These should not be accessed directly but rather via an instance of this type + private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache() + private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess") - public var cache: OGMCacheType { return mutableCache.wrappedValue } + internal var _mutableCache: Atomic + public var mutableCache: Atomic { + get { + Dependencies.getMutableValueSettingIfNull(&_mutableCache) { + OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } + } + } + } + public var cache: OGMCacheType { + get { + Dependencies.getValueSettingIfNull(&_mutableCache) { + OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } + } + } + set { + guard let mutableValue: OGMMutableCacheType = newValue as? OGMMutableCacheType else { return } + + _mutableCache.mutate { $0 = mutableValue } + } + } public init( subscribeQueue: DispatchQueue? = nil, receiveQueue: DispatchQueue? = nil, - cache: Atomic? = nil, + cache: OGMMutableCacheType? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift index a86383993..e1a8945da 100644 --- a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -8,6 +8,7 @@ public enum OpenGroupAPIError: LocalizedError { case noPublicKey case invalidEmoji case invalidPreparedData + case invalidPoll public var errorDescription: String? { switch self { @@ -16,6 +17,7 @@ public enum OpenGroupAPIError: LocalizedError { case .noPublicKey: return "Couldn't find server public key." case .invalidEmoji: return "The emoji is invalid." case .invalidPreparedData: return "Invalid PreparedSendData provided." + case .invalidPoll: return "Poller in invalid state." } } } diff --git a/SessionMessagingKit/SMKDependencies.swift b/SessionMessagingKit/SMKDependencies.swift index 8b763356e..34b9b9e6d 100644 --- a/SessionMessagingKit/SMKDependencies.swift +++ b/SessionMessagingKit/SMKDependencies.swift @@ -61,7 +61,7 @@ public class SMKDependencies: SSKDependencies { subscribeQueue: DispatchQueue? = nil, receiveQueue: DispatchQueue? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 92385940f..b61e3bcb4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -200,7 +200,7 @@ extension MessageReceiver { // Open groups for openGroupURL in message.openGroups { if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) { - OpenGroupManager.shared + let successfullyAddedGroup: Bool = OpenGroupManager.shared .add( db, roomToken: room, @@ -208,7 +208,20 @@ extension MessageReceiver { publicKey: publicKey, calledFromConfigHandling: true ) - .sinkUntilComplete() + + if successfullyAddedGroup { + db.afterNextTransactionNested { _ in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: room, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) + .subscribe(on: OpenGroupAPI.workQueue) + .sinkUntilComplete() + } + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index a6722a847..5cecfca71 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -12,130 +12,130 @@ extension MessageSender { public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:]) public static func createClosedGroup( - _ db: Database, name: String, members: Set - ) throws -> AnyPublisher { - let userPublicKey: String = getUserHexEncodedPublicKey(db) - var members: Set = members - - // Generate the group's public key - let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair() - let groupPublicKey: String = KeyPair( - publicKey: groupKeyPair.publicKey.bytes, - secretKey: groupKeyPair.privateKey.bytes - ).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix - // Generate the key pair that'll be used for encryption and decryption - let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair() - - // Create the group - members.insert(userPublicKey) // Ensure the current user is included in the member list - let membersAsData: [Data] = members.map { Data(hex: $0) } - let admins: Set = [ userPublicKey ] - let adminsAsData: [Data] = admins.map { Data(hex: $0) } - let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - - // Create the relevant objects in the database - let thread: SessionThread = try SessionThread - .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true) - try ClosedGroup( - threadId: groupPublicKey, - name: name, - formationTimestamp: formationTimestamp - ).insert(db) - - // Store the key pair - let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - try ClosedGroupKeyPair( - threadId: groupPublicKey, - publicKey: encryptionKeyPair.publicKey, - secretKey: encryptionKeyPair.privateKey, - receivedTimestamp: latestKeyPairReceivedTimestamp - ).insert(db) - - // Create the member objects - try admins.forEach { adminId in - try GroupMember( - groupId: groupPublicKey, - profileId: adminId, - role: .admin, - isHidden: false - ).save(db) - } - - try members.forEach { memberId in - try GroupMember( - groupId: groupPublicKey, - profileId: memberId, - role: .standard, - isHidden: false - ).save(db) - } - - // Update libSession - try SessionUtil.add( - db, - groupPublicKey: groupPublicKey, - name: name, - latestKeyPairPublicKey: encryptionKeyPair.publicKey, - latestKeyPairSecretKey: encryptionKeyPair.privateKey, - latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp, - disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey), - members: members, - admins: admins - ) - - let memberSendData: [MessageSender.PreparedSendData] = try members - .map { memberId -> MessageSender.PreparedSendData in - try MessageSender.preparedSendData( + ) -> AnyPublisher { + Storage.shared + .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + var members: Set = members + + // Generate the group's public key + let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair() + let groupPublicKey: String = KeyPair( + publicKey: groupKeyPair.publicKey.bytes, + secretKey: groupKeyPair.privateKey.bytes + ).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix + // Generate the key pair that'll be used for encryption and decryption + let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair() + + // Create the group + members.insert(userPublicKey) // Ensure the current user is included in the member list + let membersAsData: [Data] = members.map { Data(hex: $0) } + let admins: Set = [ userPublicKey ] + let adminsAsData: [Data] = admins.map { Data(hex: $0) } + let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + + // Create the relevant objects in the database + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true) + try ClosedGroup( + threadId: groupPublicKey, + name: name, + formationTimestamp: formationTimestamp + ).insert(db) + + // Store the key pair + let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: encryptionKeyPair.publicKey, + secretKey: encryptionKeyPair.privateKey, + receivedTimestamp: latestKeyPairReceivedTimestamp + ).insert(db) + + // Create the member objects + try admins.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin, + isHidden: false + ).save(db) + } + + try members.forEach { memberId in + try GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard, + isHidden: false + ).save(db) + } + + // Update libSession + try SessionUtil.add( db, - message: ClosedGroupControlMessage( - kind: .new( - publicKey: Data(hex: groupPublicKey), - name: name, - encryptionKeyPair: KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ), - members: membersAsData, - admins: adminsAsData, - expirationTimer: 0 - ), - // Note: We set this here to ensure the value matches - // the 'ClosedGroup' object we created - sentTimestampMs: UInt64(floor(formationTimestamp * 1000)) - ), - to: .contact(publicKey: memberId), - namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, - interactionId: nil + groupPublicKey: groupPublicKey, + name: name, + latestKeyPairPublicKey: encryptionKeyPair.publicKey, + latestKeyPairSecretKey: encryptionKeyPair.privateKey, + latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp, + disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey), + members: members, + admins: admins ) - } - - return Publishers - .MergeMany( - // Send a closed group update message to all members individually - memberSendData - .map { MessageSender.sendImmediate(preparedSendData: $0) } - .appending( - // Notify the PN server - PushNotificationAPI.performOperation( - .subscribe, - for: groupPublicKey, - publicKey: userPublicKey + + let memberSendData: [MessageSender.PreparedSendData] = try members + .map { memberId -> MessageSender.PreparedSendData in + try MessageSender.preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .new( + publicKey: Data(hex: groupPublicKey), + name: name, + encryptionKeyPair: KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ), + members: membersAsData, + admins: adminsAsData, + expirationTimer: 0 + ), + // Note: We set this here to ensure the value matches + // the 'ClosedGroup' object we created + sentTimestampMs: UInt64(floor(formationTimestamp * 1000)) + ), + to: .contact(publicKey: memberId), + namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, + interactionId: nil ) - ) - ) - .collect() - .map { _ in thread } - .eraseToAnyPublisher() - .handleEvents( - receiveCompletion: { result in - switch result { - case .failure: break - case .finished: - // Start polling - ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) } + + return (userPublicKey, thread, memberSendData) + } + .flatMap { userPublicKey, thread, memberSendData in + Publishers + .MergeMany( + // Send a closed group update message to all members individually + memberSendData + .map { MessageSender.sendImmediate(preparedSendData: $0) } + .appending( + // Notify the PN server + PushNotificationAPI.performOperation( + .subscribe, + for: thread.id, + publicKey: userPublicKey + ) + ) + ) + .collect() + .map { _ in thread } + } + .handleEvents( + receiveOutput: { thread in + // Start polling + ClosedGroupPoller.shared.startIfNeeded(for: thread.id) } ) .eraseToAnyPublisher() @@ -148,7 +148,6 @@ extension MessageSender { /// /// The returned promise is fulfilled when the message has been sent to the group. private static func generateAndSendNewEncryptionKeyPair( - _ db: Database, targetMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], @@ -159,65 +158,62 @@ extension MessageSender { .eraseToAnyPublisher() } - let newKeyPair: ClosedGroupKeyPair - let sendData: MessageSender.PreparedSendData - - do { - // Generate the new encryption key pair - let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() - newKeyPair = ClosedGroupKeyPair( - threadId: closedGroup.threadId, - publicKey: legacyNewKeyPair.publicKey, - secretKey: legacyNewKeyPair.privateKey, - receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ) - - // Distribute it - let proto = try SNProtoKeyPair.builder( - publicKey: newKeyPair.publicKey, - privateKey: newKeyPair.secretKey - ).build() - let plaintext = try proto.serializedData() - - distributingKeyPairs.mutate { - $0[closedGroup.id] = ($0[closedGroup.id] ?? []) - .appending(newKeyPair) - } - - sendData = try MessageSender - .preparedSendData( - db, - message: ClosedGroupControlMessage( - kind: .encryptionKeyPair( - publicKey: nil, - wrappers: targetMembers.map { memberPublicKey in - ClosedGroupControlMessage.KeyPairWrapper( - publicKey: memberPublicKey, - encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( - db, - plaintext: plaintext, - for: memberPublicKey - ) - ) - } - ) - ), - to: try Message.Destination - .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup), - namespace: try Message.Destination - .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) - .defaultNamespace, - interactionId: nil + return Storage.shared + .readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in + // Generate the new encryption key pair + let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( + threadId: closedGroup.threadId, + publicKey: legacyNewKeyPair.publicKey, + secretKey: legacyNewKeyPair.privateKey, + receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) ) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - - return MessageSender.sendImmediate(preparedSendData: sendData) - .map { _ in newKeyPair } - .eraseToAnyPublisher() + + // Distribute it + let proto = try SNProtoKeyPair.builder( + publicKey: newKeyPair.publicKey, + privateKey: newKeyPair.secretKey + ).build() + let plaintext = try proto.serializedData() + + distributingKeyPairs.mutate { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .appending(newKeyPair) + } + + let sendData: MessageSender.PreparedSendData = try MessageSender + .preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .encryptionKeyPair( + publicKey: nil, + wrappers: targetMembers.map { memberPublicKey in + ClosedGroupControlMessage.KeyPairWrapper( + publicKey: memberPublicKey, + encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( + db, + plaintext: plaintext, + for: memberPublicKey + ) + ) + } + ) + ), + to: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup), + namespace: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) + .defaultNamespace, + interactionId: nil + ) + + return (newKeyPair, sendData) + } + .flatMap { newKeyPair, sendData -> AnyPublisher in + MessageSender.sendImmediate(preparedSendData: sendData) + .map { _ in newKeyPair } + .eraseToAnyPublisher() + } .handleEvents( receiveOutput: { newKeyPair in /// Store it **after** having sent out the message to the group @@ -253,116 +249,110 @@ extension MessageSender { } public static func update( - _ db: Database, groupPublicKey: String, with members: Set, name: String ) -> AnyPublisher { - // Get the group, check preconditions & prepare - guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else { - SNLog("Can't update nonexistent closed group.") - return Fail(error: MessageSenderError.noThread) - .eraseToAnyPublisher() - } - guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate) - .eraseToAnyPublisher() - } - - let userPublicKey: String = getUserHexEncodedPublicKey(db) - - do { - // Update name if needed - if name != closedGroup.name { - // Update the group - _ = try ClosedGroup - .filter(id: closedGroup.id) - .updateAll(db, ClosedGroup.Columns.name.set(to: name)) + return Storage.shared + .writePublisher { db -> (String, ClosedGroup, [GroupMember], Set) in + let userPublicKey: String = getUserHexEncodedPublicKey(db) - // Notify the user - let interaction: Interaction = try Interaction( - threadId: groupPublicKey, - authorId: userPublicKey, - variant: .infoClosedGroupUpdated, - body: ClosedGroupControlMessage.Kind - .nameChange(name: name) - .infoMessage(db, sender: userPublicKey), - timestampMs: SnodeAPI.currentOffsetTimestampMs() - ).inserted(db) + // Get the group, check preconditions & prepare + guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else { + SNLog("Can't update nonexistent closed group.") + throw MessageSenderError.noThread + } + guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else { + throw MessageSenderError.invalidClosedGroupUpdate + } - guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + // Update name if needed + if name != closedGroup.name { + // Update the group + _ = try ClosedGroup + .filter(id: closedGroup.id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) + + // Notify the user + let interaction: Interaction = try Interaction( + threadId: groupPublicKey, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .nameChange(name: name) + .infoMessage(db, sender: userPublicKey), + timestampMs: SnodeAPI.currentOffsetTimestampMs() + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + + // Send the update to the group + try MessageSender.send( + db, + message: ClosedGroupControlMessage(kind: .nameChange(name: name)), + interactionId: interactionId, + threadId: groupPublicKey, + threadVariant: .legacyGroup + ) + + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: closedGroup.threadId, + name: name + ) + } - // Send the update to the group - try MessageSender.send( - db, - message: ClosedGroupControlMessage(kind: .nameChange(name: name)), - interactionId: interactionId, - threadId: groupPublicKey, - threadVariant: .legacyGroup - ) + // Retrieve member info + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + throw MessageSenderError.invalidClosedGroupUpdate + } - // Update libSession - try? SessionUtil.update( - db, - groupPublicKey: closedGroup.threadId, - name: name + let standardAndZombieMemberIds: [String] = allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + let addedMembers: Set = members.subtracting(standardAndZombieMemberIds) + + // Add members if needed + if !addedMembers.isEmpty { + do { + try addMembers( + db, + addedMembers: addedMembers, + userPublicKey: userPublicKey, + allGroupMembers: allGroupMembers, + closedGroup: closedGroup + ) + } + catch { + throw MessageSenderError.invalidClosedGroupUpdate + } + } + + // Remove members if needed + return ( + userPublicKey, + closedGroup, + allGroupMembers, + Set(standardAndZombieMemberIds).subtracting(members) ) } - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - - // Retrieve member info - guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate) - .eraseToAnyPublisher() - } - - let standardAndZombieMemberIds: [String] = allGroupMembers - .filter { $0.role == .standard || $0.role == .zombie } - .map { $0.profileId } - let addedMembers: Set = members.subtracting(standardAndZombieMemberIds) - - // Add members if needed - if !addedMembers.isEmpty { - do { - try addMembers( - db, - addedMembers: addedMembers, - userPublicKey: userPublicKey, - allGroupMembers: allGroupMembers, - closedGroup: closedGroup - ) - } - catch { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate) - .eraseToAnyPublisher() - } - } - - // Remove members if needed - let removedMembers: Set = Set(standardAndZombieMemberIds).subtracting(members) - - if !removedMembers.isEmpty { - do { - return try removeMembers( - db, + .flatMap { userPublicKey, closedGroup, allGroupMembers, removedMembers -> AnyPublisher in + guard !removedMembers.isEmpty else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return removeMembers( removedMembers: removedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, closedGroup: closedGroup ) + .catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() } + .eraseToAnyPublisher() } - catch { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate) - .eraseToAnyPublisher() - } - } - - return Just(()) - .setFailureType(to: Error.self) .eraseToAnyPublisher() } @@ -476,19 +466,20 @@ extension MessageSender { /// The returned promise is fulfilled when the `MEMBERS_REMOVED` message has been sent to the group AND the new encryption key pair has been /// generated and distributed. private static func removeMembers( - _ db: Database, removedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], closedGroup: ClosedGroup - ) throws -> AnyPublisher { + ) -> AnyPublisher { guard !removedMembers.contains(userPublicKey) else { SNLog("Invalid closed group update.") - throw MessageSenderError.invalidClosedGroupUpdate + return Fail(error: MessageSenderError.invalidClosedGroupUpdate) + .eraseToAnyPublisher() } guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { SNLog("Only an admin can remove members from a group.") - throw MessageSenderError.invalidClosedGroupUpdate + return Fail(error: MessageSenderError.invalidClosedGroupUpdate) + .eraseToAnyPublisher() } let groupMemberIds: [String] = allGroupMembers @@ -499,39 +490,39 @@ extension MessageSender { .map { $0.profileId } let members: Set = Set(groupMemberIds).subtracting(removedMembers) - // Update zombie & member list - try GroupMember - .filter(GroupMember.Columns.groupId == closedGroup.threadId) - .filter(removedMembers.contains(GroupMember.Columns.profileId)) - .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) - .deleteAll(db) - - let interactionId: Int64? - - // Notify the user if needed (not if only zombie members were removed) - if !removedMembers.subtracting(groupZombieIds).isEmpty { - let interaction: Interaction = try Interaction( - threadId: closedGroup.threadId, - authorId: userPublicKey, - variant: .infoClosedGroupUpdated, - body: ClosedGroupControlMessage.Kind - .membersRemoved(members: removedMembers.map { Data(hex: $0) }) - .infoMessage(db, sender: userPublicKey), - timestampMs: SnodeAPI.currentOffsetTimestampMs() - ).inserted(db) - - guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } - - interactionId = newInteractionId - } - else { - interactionId = nil - } - - // Send the update to the group and generate + distribute a new encryption key pair - return MessageSender - .sendImmediate( - preparedSendData: try MessageSender + return Storage.shared + .writePublisher { db in + // Update zombie & member list + try GroupMember + .filter(GroupMember.Columns.groupId == closedGroup.threadId) + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) + .deleteAll(db) + + let interactionId: Int64? + + // Notify the user if needed (not if only zombie members were removed) + if !removedMembers.subtracting(groupZombieIds).isEmpty { + let interaction: Interaction = try Interaction( + threadId: closedGroup.threadId, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .membersRemoved(members: removedMembers.map { Data(hex: $0) }) + .infoMessage(db, sender: userPublicKey), + timestampMs: SnodeAPI.currentOffsetTimestampMs() + ).inserted(db) + + guard let newInteractionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + + interactionId = newInteractionId + } + else { + interactionId = nil + } + + // Send the update to the group and generate + distribute a new encryption key pair + return try MessageSender .preparedSendData( db, message: ClosedGroupControlMessage( @@ -546,18 +537,15 @@ extension MessageSender { .defaultNamespace, interactionId: interactionId ) - ) + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } .flatMap { _ -> AnyPublisher in - Storage.shared - .writePublisherFlatMap { db in - generateAndSendNewEncryptionKeyPair( - db, - targetMembers: members, - userPublicKey: userPublicKey, - allGroupMembers: allGroupMembers, - closedGroup: closedGroup - ) - } + MessageSender.generateAndSendNewEncryptionKeyPair( + targetMembers: members, + userPublicKey: userPublicKey, + allGroupMembers: allGroupMembers, + closedGroup: closedGroup + ) } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 873319035..41d72b455 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -57,38 +57,47 @@ extension OpenGroupAPI { ) { guard hasStarted else { return } - let minPollFailureCount: TimeInterval = Storage.shared - .read { db in + dependencies.storage + .readPublisher { [server = server] db in try OpenGroup .filter(OpenGroup.Columns.server == server) .select(min(OpenGroup.Columns.pollFailureCount)) .asRequest(of: TimeInterval.self) .fetchOne(db) } - .defaulting(to: 0) - let lastPollStart: TimeInterval = Date().timeIntervalSince1970 - let nextPollInterval: TimeInterval = getInterval(for: minPollFailureCount, minInterval: Poller.minPollInterval, maxInterval: Poller.maxPollInterval) - - // Wait until the last poll completes before polling again ensuring we don't poll any faster than - // the 'nextPollInterval' value - poll(using: dependencies) + .tryFlatMap { [weak self] minPollFailureCount -> AnyPublisher<(TimeInterval, TimeInterval), Error> in + guard let strongSelf = self else { throw OpenGroupAPIError.invalidPoll } + + let lastPollStart: TimeInterval = Date().timeIntervalSince1970 + let nextPollInterval: TimeInterval = Poller.getInterval( + for: (minPollFailureCount ?? 0), + minInterval: Poller.minPollInterval, + maxInterval: Poller.maxPollInterval + ) + + // Wait until the last poll completes before polling again ensuring we don't poll any faster than + // the 'nextPollInterval' value + return strongSelf.poll(using: dependencies) + .map { _ in (lastPollStart, nextPollInterval) } + .eraseToAnyPublisher() + } .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) .sinkUntilComplete( - receiveCompletion: { [weak self] _ in + receiveValue: { [weak self] lastPollStart, nextPollInterval in let currentTime: TimeInterval = Date().timeIntervalSince1970 let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart)) - + guard remainingInterval > 0 else { - return Threading.pollerQueue.async { + return dependencies.subscribeQueue.async { self?.pollRecursively(using: dependencies) } } - + self?.timer = Timer.scheduledTimerOnMainThread(withTimeInterval: remainingInterval, repeats: false) { timer in timer.invalidate() - - Threading.pollerQueue.async { + + dependencies.subscribeQueue.async { self?.pollRecursively(using: dependencies) } } @@ -120,6 +129,11 @@ extension OpenGroupAPI { self.isPolling = true let server: String = self.server + let hasPerformedInitialPoll: Bool = (dependencies.cache.hasPerformedInitialPoll[server] == true) + let timeSinceLastPoll: TimeInterval = ( + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.mutableCache.mutate { $0.getTimeSinceLastOpen(using: dependencies) } + ) return dependencies.storage .readPublisher { db -> (Int64, PreparedSendData) in @@ -136,11 +150,8 @@ extension OpenGroupAPI { .preparedPoll( db, server: server, - hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, - timeSinceLastPoll: ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.cache.getTimeSinceLastOpen(using: dependencies) - ), + hasPerformedInitialPoll: hasPerformedInitialPoll, + timeSinceLastPoll: timeSinceLastPoll, using: dependencies ) ) @@ -591,12 +602,12 @@ extension OpenGroupAPI { } } } - } - - // MARK: - Convenience + + // MARK: - Convenience - fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval { - // Arbitrary backoff factor... - return min(maxInterval, minInterval + pow(2, failureCount)) + fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval { + // Arbitrary backoff factor... + return min(maxInterval, minInterval + pow(2, failureCount)) + } } } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift index 3433ea5dd..ba9210a74 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift @@ -138,7 +138,7 @@ internal extension SessionUtil { // Add any new communities (via the OpenGroupManager) communities.forEach { community in - OpenGroupManager.shared + let successfullyAddedGroup: Bool = OpenGroupManager.shared .add( db, roomToken: community.data.roomToken, @@ -146,7 +146,20 @@ internal extension SessionUtil { publicKey: community.data.publicKey, calledFromConfigHandling: true ) - .sinkUntilComplete() + + if successfullyAddedGroup { + db.afterNextTransactionNested { _ in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: community.data.roomToken, + server: community.data.server, + publicKey: community.data.publicKey, + calledFromConfigHandling: false + ) + .subscribe(on: OpenGroupAPI.workQueue) + .sinkUntilComplete() + } + } // Set the priority if it's changed (new communities will have already been inserted at // this stage) diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift index 44eb2f20b..204068860 100644 --- a/SessionMessagingKit/SessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -403,40 +403,35 @@ public enum SessionUtil { guard SessionUtil.userConfigsEnabled else { return [] } return Storage.shared - .read { db -> [String] in + .read { db -> Set in guard Identity.userExists(db) else { return [] } - let existingDumpVariants: Set = (try? ConfigDump + return try ConfigDump .select(.variant) .filter(ConfigDump.Columns.publicKey == publicKey) .asRequest(of: ConfigDump.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - - /// Extract all existing hashes for any dumps associated with the given `publicKey` - return existingDumpVariants - .map { variant -> [String] in - guard - let conf = SessionUtil - .config(for: variant, publicKey: publicKey) - .wrappedValue, - let hashList: UnsafeMutablePointer = config_current_hashes(conf) - else { - return [] - } - - let result: [String] = [String]( - pointer: hashList.pointee.value, - count: hashList.pointee.len, - defaultValue: [] - ) - hashList.deallocate() - - return result - } - .reduce([], +) + .fetchSet(db) } .defaulting(to: []) + .map { variant -> [String] in + /// Extract all existing hashes for any dumps associated with the given `publicKey` + guard + let conf = SessionUtil + .config(for: variant, publicKey: publicKey) + .wrappedValue, + let hashList: UnsafeMutablePointer = config_current_hashes(conf) + else { return [] } + + let result: [String] = [String]( + pointer: hashList.pointee.value, + count: hashList.pointee.len, + defaultValue: [] + ) + hashList.deallocate() + + return result + } + .reduce([], +) } // MARK: - Receiving diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index ea0753690..3089f59f1 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -498,7 +498,7 @@ public struct ProfileManager { dependencies: Dependencies = Dependencies() ) throws { let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) - let profile: Profile = Profile.fetchOrCreate(id: publicKey) + let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) var profileChanges: [ConfigColumnAssignment] = [] // Name diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 8beeb921b..b2c69c409 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -118,9 +118,9 @@ class OpenGroupManagerSpec: QuickSpec { dependencies = OpenGroupManager.OGMDependencies( subscribeQueue: DispatchQueue.main, receiveQueue: DispatchQueue.main, - cache: Atomic(mockOGMCache), + cache: mockOGMCache, onionApi: TestCapabilitiesAndRoomApi.self, - generalCache: Atomic(mockGeneralCache), + generalCache: mockGeneralCache, storage: mockStorage, sodium: mockSodium, genericHash: mockGenericHash, diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index cef200b39..ec2b8ac10 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -6,9 +6,9 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class MockOGMCache: Mock, OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupAPI.Room], Error>? { - get { return accept() as? AnyPublisher<[OpenGroupAPI.Room], Error> } +class MockOGMCache: Mock, OGMMutableCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { + get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> } set { accept(args: [newValue]) } } diff --git a/SessionSnodeKit/SSKDependencies.swift b/SessionSnodeKit/SSKDependencies.swift index 01faa2289..875762f75 100644 --- a/SessionSnodeKit/SSKDependencies.swift +++ b/SessionSnodeKit/SSKDependencies.swift @@ -17,7 +17,7 @@ open class SSKDependencies: Dependencies { subscribeQueue: DispatchQueue? = nil, receiveQueue: DispatchQueue? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, diff --git a/SessionUtilitiesKit/Combine/ReplaySubject.swift b/SessionUtilitiesKit/Combine/ReplaySubject.swift index 7648fa989..428e58d9c 100644 --- a/SessionUtilitiesKit/Combine/ReplaySubject.swift +++ b/SessionUtilitiesKit/Combine/ReplaySubject.swift @@ -49,10 +49,23 @@ public final class ReplaySubject: Subject { public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { lock.lock(); defer { lock.unlock() } - let subscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) + /// According to the below comment the `subscriber.receive(subscription: subscription)` code runs asynchronously + /// which aligns with testing (resulting in the `request(_ newDemand: Subscribers.Demand)` function getting called after this + /// function returns + /// + /// Later in the thread it's mentioned that as of `iOS 13.3` this behaviour changed to be synchronous but as of writing the minimum + /// deployment version is set to `iOS 13.0` which I assume is why we are seeing the async behaviour which results in `receiveValue` + /// not being called in some cases + /// + /// When the project is eventually updated to have a minimum version higher than `iOS 13.3` we should re-test this behaviour to see if + /// we can revert this change + /// + /// https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/20 + let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) { [buffer = buffer, completion = completion] subscription in + subscription.replay(buffer, completion: completion) + } subscriber.receive(subscription: subscription) subscriptions.append(subscription) - subscription.replay(buffer, completion: completion) } } @@ -62,17 +75,21 @@ public final class ReplaySubjectSubscription: Subscripti private let downstream: AnySubscriber private var isCompleted: Bool = false private var demand: Subscribers.Demand = .none + private var onInitialDemand: ((ReplaySubjectSubscription) -> ())? // MARK: - Initialization - init(downstream: AnySubscriber) { + init(downstream: AnySubscriber, onInitialDemand: @escaping (ReplaySubjectSubscription) -> ()) { self.downstream = downstream + self.onInitialDemand = onInitialDemand } // MARK: - Subscription public func request(_ newDemand: Subscribers.Demand) { demand += newDemand + onInitialDemand?(self) + onInitialDemand = nil } public func cancel() { diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 4cd35ccce..1feb26663 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -516,24 +516,6 @@ open class Storage { // MARK: - Combine Extensions -public extension Storage { - func readPublisherFlatMap( - value: @escaping (Database) throws -> AnyPublisher - ) -> AnyPublisher { - return readPublisher(value: value) - .flatMap { resultPublisher -> AnyPublisher in resultPublisher } - .eraseToAnyPublisher() - } - - func writePublisherFlatMap( - updates: @escaping (Database) throws -> AnyPublisher - ) -> AnyPublisher { - return writePublisher(updates: updates) - .flatMap { resultPublisher -> AnyPublisher in resultPublisher } - .eraseToAnyPublisher() - } -} - public extension ValueObservation { func publisher( in storage: Storage, diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 64e2d5f3e..61da1dc05 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -4,6 +4,10 @@ import Foundation import GRDB open class Dependencies { + /// These should not be accessed directly but rather via an instance of this type + private static let _generalCacheInstance: MutableGeneralCacheType = General.Cache() + private static let _generalCacheInstanceAccessQueue = DispatchQueue(label: "GeneralCacheInstanceAccess") + public var _subscribeQueue: Atomic public var subscribeQueue: DispatchQueue { get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } } @@ -16,10 +20,25 @@ open class Dependencies { set { _receiveQueue.mutate { $0 = newValue } } } - public var _generalCache: Atomic?> - public var generalCache: Atomic { - get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } - set { _generalCache.mutate { $0 = newValue } } + public var _mutableGeneralCache: Atomic + public var mutableGeneralCache: Atomic { + get { + Dependencies.getMutableValueSettingIfNull(&_mutableGeneralCache) { + Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } + } + } + } + public var generalCache: GeneralCacheType { + get { + Dependencies.getValueSettingIfNull(&_mutableGeneralCache) { + Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } + } + } + set { + guard let mutableValue: MutableGeneralCacheType = newValue as? MutableGeneralCacheType else { return } + + _mutableGeneralCache.mutate { $0 = mutableValue } + } } public var _storage: Atomic @@ -51,7 +70,7 @@ open class Dependencies { public init( subscribeQueue: DispatchQueue? = nil, receiveQueue: DispatchQueue? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, @@ -59,7 +78,7 @@ open class Dependencies { ) { _subscribeQueue = Atomic(subscribeQueue) _receiveQueue = Atomic(receiveQueue) - _generalCache = Atomic(generalCache) + _mutableGeneralCache = Atomic(generalCache) _storage = Atomic(storage) _scheduler = Atomic(scheduler) _standardUserDefaults = Atomic(standardUserDefaults) @@ -77,4 +96,14 @@ open class Dependencies { return value } + + public static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { + guard let value: T = maybeValue.wrappedValue else { + let value: T = valueGenerator() + maybeValue.mutate { $0 = value } + return Atomic(value) + } + + return Atomic(value) + } } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 9c7e9f0f4..5f59f2b13 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -3,35 +3,48 @@ import Foundation import GRDB -public protocol GeneralCacheType { - var encodedPublicKey: String? { get set } - var recentReactionTimestamps: [Int64] { get set } -} +// MARK: - General.Cache public enum General { - public class Cache: GeneralCacheType { + public class Cache: MutableGeneralCacheType { public var encodedPublicKey: String? = nil public var recentReactionTimestamps: [Int64] = [] } - - public static var cache: Atomic = Atomic(Cache()) } +// MARK: - GeneralError + public enum GeneralError: Error { case invalidSeed case keyGenerationFailed case randomGenerationFailed } +// MARK: - Convenience + public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String { - if let cachedKey: String = dependencies.generalCache.wrappedValue.encodedPublicKey { return cachedKey } + if let cachedKey: String = dependencies.generalCache.encodedPublicKey { return cachedKey } if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes) - dependencies.generalCache.mutate { $0.encodedPublicKey = sessionId.hexString } + dependencies.mutableGeneralCache.mutate { $0.encodedPublicKey = sessionId.hexString } return sessionId.hexString } return "" } + +// MARK: - GeneralCacheType + +public protocol MutableGeneralCacheType: GeneralCacheType { + var encodedPublicKey: String? { get set } + var recentReactionTimestamps: [Int64] { get set } +} + +/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol GeneralCacheType { + var encodedPublicKey: String? { get } + var recentReactionTimestamps: [Int64] { get } +} From 5e2e103ee162a2ceff6d47b66438b53d7aedbcbc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 29 Jun 2023 12:11:54 +1000 Subject: [PATCH 3/3] Resolved the remaining known internal testing issues Removed the 'immediatelyOnMain' extensions as they would break in some cases (eg. upstream errors with multiple 'receive(on:)' calls) resulting in logic running on unexpected threads Updated the ReplaySubject to add subscribers in an Atomic just to be safe Updated the code to remove the invalid open group when the user receives an error after joining Fixed a bug with editing closed group members Fixed broken unit tests --- Session/Calls/CallVC.swift | 1 - Session/Calls/VideoPreviewVC.swift | 1 - .../Views & Modals/IncomingCallBanner.swift | 1 - Session/Closed Groups/EditClosedGroupVC.swift | 3 +- .../ConversationVC+Interaction.swift | 12 + Session/Meta/AppDelegate.swift | 1 - Session/Open Groups/JoinOpenGroupVC.swift | 22 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 4 +- .../Shared/SessionTableViewController.swift | 13 +- Session/Utilities/BackgroundPoller.swift | 4 +- .../Control Messages/CallMessage.swift | 1 - .../Open Groups/OpenGroupManager.swift | 5 +- .../Pollers/OpenGroupPoller.swift | 4 +- .../Sending & Receiving/Pollers/Poller.swift | 8 +- .../Open Groups/OpenGroupManagerSpec.swift | 240 ++++++++++-------- .../_TestUtilities/DependencyExtensions.swift | 4 +- .../OGMDependencyExtensions.swift | 6 +- SessionShareExtension/ThreadPickerVC.swift | 2 +- ...eadDisappearingMessagesViewModelSpec.swift | 10 +- .../ThreadSettingsViewModelSpec.swift | 10 +- .../NotificationContentViewModelSpec.swift | 6 +- .../Combine/Publisher+Utilities.swift | 58 ----- .../Combine/ReplaySubject.swift | 11 +- _SharedTestUtilities/CombineExtensions.swift | 6 +- _SharedTestUtilities/MockGeneralCache.swift | 2 +- 25 files changed, 204 insertions(+), 231 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 88a665d0c..c7657d8a5 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -3,7 +3,6 @@ import UIKit import YYImage import MediaPlayer -import WebRTC import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit diff --git a/Session/Calls/VideoPreviewVC.swift b/Session/Calls/VideoPreviewVC.swift index 4311d961b..b75b05b16 100644 --- a/Session/Calls/VideoPreviewVC.swift +++ b/Session/Calls/VideoPreviewVC.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import WebRTC import SessionUIKit public protocol VideoPreviewDelegate: AnyObject { diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index 98fdbf424..7646903a2 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import WebRTC import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 11161e534..07475227c 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -465,7 +465,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in Storage.shared .writePublisher { db in - guard updatedMemberIds.contains(userPublicKey) else { return } + // If the user is no longer a member then leave the group + guard !updatedMemberIds.contains(userPublicKey) else { return } try MessageSender.leave( db, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index c3073d9ce..13704aadb 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1552,6 +1552,18 @@ extension ConversationVC: switch result { case .finished: break case .failure(let error): + // If there was a failure then the group will be in invalid state until + // the next launch so remove it (the user will be left on the previous + // screen so can re-trigger the join) + Storage.shared.writeAsync { db in + OpenGroupManager.shared.delete( + db, + openGroupId: OpenGroup.idFor(roomToken: room, server: server), + calledFromConfigHandling: false + ) + } + + // Show the user an error indicating they failed to properly join the group let errorModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "COMMUNITY_ERROR_GENERIC".localized(), diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 21563c96b..82f373b48 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -4,7 +4,6 @@ import UIKit import Combine import UserNotifications import GRDB -import WebRTC import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index b5f214cc9..5c7e19df5 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -193,11 +193,25 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC receiveCompletion: { result in switch result { case .failure(let error): - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - let title = "COMMUNITY_ERROR_GENERIC".localized() - let message = error.localizedDescription + // If there was a failure then the group will be in invalid state until + // the next launch so remove it (the user will be left on the previous + // screen so can re-trigger the join) + Storage.shared.writeAsync { db in + OpenGroupManager.shared.delete( + db, + openGroupId: OpenGroup.idFor(roomToken: roomToken, server: server), + calledFromConfigHandling: false + ) + } + + // Show the user an error indicating they failed to properly join the group self?.isJoining = false - self?.showError(title: title, message: message) + self?.dismiss(animated: true) { // Dismiss the loader + self?.showError( + title: "COMMUNITY_ERROR_GENERIC".localized(), + message: error.localizedDescription + ) + } case .finished: self?.presentingViewController?.dismiss(animated: true, completion: nil) diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index f5e4718aa..f7037bc55 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -144,7 +144,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle OpenGroupManager.getDefaultRoomsIfNeeded() .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in switch result { @@ -340,7 +340,7 @@ extension OpenGroupSuggestionGrid { .eraseToAnyPublisher() ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveValue: { [weak self] imageData, hasData in guard hasData else { diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 1be684f0b..f1a01d854 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -187,13 +187,7 @@ class SessionTableViewController [DefaultRoomInfo]? in dependencies.storage.write { db -> [DefaultRoomInfo] in diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 41d72b455..a4cce3c35 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -81,8 +81,8 @@ extension OpenGroupAPI { .map { _ in (lastPollStart, nextPollInterval) } .eraseToAnyPublisher() } - .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) - .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) .sinkUntilComplete( receiveValue: { [weak self] lastPollStart, nextPollInterval in let currentTime: TimeInterval = Date().timeIntervalSince1970 diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index e69afe618..277196448 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -93,7 +93,6 @@ public class Poller { let namespaces: [SnodeAPI.Namespace] = self.namespaces getSnodeForPolling(for: publicKey) - .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .flatMap { snode -> AnyPublisher<[Message], Error> in Poller.poll( namespaces: namespaces, @@ -103,7 +102,8 @@ public class Poller { using: dependencies ) } - .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) .sinkUntilComplete( receiveCompletion: { [weak self] result in switch result { @@ -134,7 +134,6 @@ public class Poller { timer.invalidate() self?.getSnodeForPolling(for: publicKey) - .subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true) .flatMap { snode -> AnyPublisher<[Message], Error> in Poller.poll( namespaces: namespaces, @@ -144,7 +143,8 @@ public class Poller { using: dependencies ) } - .receive(on: dependencies.receiveQueue, immediatelyIfMain: true) + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index b2c69c409..4adf3f940 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -367,7 +367,11 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:]) mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:]) - mockOGMCache.when { $0.getTimeSinceLastOpen(using: dependencies) }.thenReturn(0) + mockOGMCache + .when { [dependencies = dependencies!] cache in + cache.getTimeSinceLastOpen(using: dependencies) + } + .thenReturn(0) mockOGMCache.when { $0.isPolling }.thenReturn(false) mockOGMCache.when { $0.pollers }.thenReturn([:]) @@ -816,7 +820,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, @@ -827,6 +831,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) } + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -847,7 +861,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, @@ -858,6 +872,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) } + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -884,7 +908,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, @@ -897,6 +921,18 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) } + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey + .replacingOccurrences(of: "c3", with: "00") + .replacingOccurrences(of: "b3", with: "00"), + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -940,7 +976,7 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? mockStorage - .writePublisherFlatMap { (db: Database) -> AnyPublisher in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, @@ -951,6 +987,16 @@ class OpenGroupManagerSpec: QuickSpec { dependencies: dependencies ) } + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) @@ -3334,15 +3380,16 @@ class OpenGroupManagerSpec: QuickSpec { upload: false, defaultUpload: nil ) - let publisher = Future<[OpenGroupAPI.Room], Error> { resolver in - resolver(Result.success([uniqueRoomInstance])) + let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in + resolver(Result.success([(uniqueRoomInstance, nil)])) } .shareReplay(1) .eraseToAnyPublisher() mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher) let publisher2 = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - expect(publisher2.firstValue()).to(equal(publisher.firstValue())) + expect(publisher2.firstValue()?.map { $0.room }) + .to(equal(publisher.firstValue()?.map { $0.room })) } it("stores the open group information") { @@ -3376,13 +3423,13 @@ class OpenGroupManagerSpec: QuickSpec { } it("fetches rooms for the server") { - var response: [OpenGroupAPI.Room]? + var response: [OpenGroupManager.DefaultRoomInfo]? OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - .handleEvents(receiveOutput: { (data: [OpenGroupAPI.Room]) in response = data }) + .handleEvents(receiveOutput: { response = $0 }) .sinkAndStore(in: &disposables) - expect(response) + expect(response?.map { $0.room }) .toEventually( equal( [ @@ -3598,17 +3645,14 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher]) var result: Data? - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: "testServer", - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) .handleEvents(receiveOutput: { result = $0 }) .sinkAndStore(in: &disposables) @@ -3617,17 +3661,14 @@ class OpenGroupManagerSpec: QuickSpec { it("does not save the fetched image to storage") { var didComplete: Bool = false - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: "testServer", - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -3648,17 +3689,14 @@ class OpenGroupManagerSpec: QuickSpec { it("does not update the image update timestamp") { var didComplete: Bool = false - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: "testServer", - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -3690,17 +3728,14 @@ class OpenGroupManagerSpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) - let publisher = mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: "testServer", - using: dependencies - ) - } + let publisher = OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) publisher.sinkAndStore(in: &disposables) expect(mockOGMCache) @@ -3716,17 +3751,14 @@ class OpenGroupManagerSpec: QuickSpec { it("fetches a new image if there is no cached one") { var result: Data? - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) @@ -3736,17 +3768,14 @@ class OpenGroupManagerSpec: QuickSpec { it("saves the fetched image to storage") { var didComplete: Bool = false - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -3768,17 +3797,14 @@ class OpenGroupManagerSpec: QuickSpec { it("updates the image update timestamp") { var didComplete: Bool = false - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) @@ -3816,17 +3842,14 @@ class OpenGroupManagerSpec: QuickSpec { it("retrieves the cached image") { var result: Data? - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: Data([2, 3, 4]), + using: dependencies + ) .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) @@ -3846,17 +3869,14 @@ class OpenGroupManagerSpec: QuickSpec { var result: Data? - mockStorage - .readPublisherFlatMap { (db: Database) -> AnyPublisher in - OpenGroupManager - .roomImage( - db, - fileId: "1", - for: "testRoom", - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: Data([2, 3, 4]), + using: dependencies + ) .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index 38f83c578..83e6af787 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit extension SMKDependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, @@ -26,7 +26,7 @@ extension SMKDependencies { ) -> SMKDependencies { return SMKDependencies( onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._generalCache.wrappedValue), + generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index b297e62a8..a2be81109 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -9,9 +9,9 @@ import SessionUtilitiesKit extension OpenGroupManager.OGMDependencies { public func with( - cache: Atomic? = nil, + cache: OGMMutableCacheType? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, @@ -28,7 +28,7 @@ extension OpenGroupManager.OGMDependencies { return OpenGroupManager.OGMDependencies( cache: (cache ?? self._mutableCache.wrappedValue), onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._generalCache.wrappedValue), + generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 2facdea62..4728da4d3 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -160,7 +160,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ShareNavController.attachmentPrepPublisher? .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveValue: { [weak self] attachments in guard let strongSelf = self else { return } diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index 862a650e8..f583b8b3c 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -49,7 +49,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ) cancellables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -132,7 +132,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ) cancellables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -178,7 +178,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { cancellables.append( viewModel.rightNavItems - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } @@ -194,7 +194,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { beforeEach { cancellables.append( viewModel.rightNavItems - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } @@ -221,7 +221,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { cancellables.append( viewModel.dismissScreen - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index e05de6c9a..2750a3803 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -35,7 +35,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) mockGeneralCache = MockGeneralCache() dependencies = Dependencies( - generalCache: Atomic(mockGeneralCache), + generalCache: mockGeneralCache, storage: mockStorage, scheduler: .immediate ) @@ -75,7 +75,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -173,7 +173,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -447,7 +447,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -489,7 +489,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) disposables.append( viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index 6538c95b0..e4214f4da 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -31,7 +31,7 @@ class NotificationContentViewModelSpec: QuickSpec { ) viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) dataChangeCancellable = viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -99,7 +99,7 @@ class NotificationContentViewModelSpec: QuickSpec { } viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) dataChangeCancellable = viewModel.observableTableData - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0.0) } @@ -148,7 +148,7 @@ class NotificationContentViewModelSpec: QuickSpec { var didDismissScreen: Bool = false dismissCancellable = viewModel.dismissScreen - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index ddc059a85..796e9b29d 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -25,64 +25,6 @@ public extension Publisher { ) } - /// The standard `.subscribe(on: DispatchQueue.main)` seems to ocassionally dispatch to the - /// next run loop before actually subscribing, this method checks if it's running on the main thread already and - /// if so just subscribes directly rather than routing via `.receive(on:)` - func subscribe( - on scheduler: S, - immediatelyIfMain: Bool, - options: S.SchedulerOptions? = nil - ) -> AnyPublisher where S: Scheduler { - guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else { - return self.subscribe(on: scheduler, options: options) - .eraseToAnyPublisher() - } - - return self - .flatMap { value -> AnyPublisher in - guard Thread.isMainThread else { - return Just(value) - .setFailureType(to: Failure.self) - .subscribe(on: scheduler, options: options) - .eraseToAnyPublisher() - } - - return Just(value) - .setFailureType(to: Failure.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - /// The standard `.receive(on: DispatchQueue.main)` seems to ocassionally dispatch to the - /// next run loop before emitting data, this method checks if it's running on the main thread already and - /// if so just emits directly rather than routing via `.receive(on:)` - func receive( - on scheduler: S, - immediatelyIfMain: Bool, - options: S.SchedulerOptions? = nil - ) -> AnyPublisher where S: Scheduler { - guard immediatelyIfMain && ((scheduler as? DispatchQueue) == DispatchQueue.main) else { - return self.receive(on: scheduler, options: options) - .eraseToAnyPublisher() - } - - return self - .flatMap { value -> AnyPublisher in - guard Thread.isMainThread else { - return Just(value) - .setFailureType(to: Failure.self) - .receive(on: scheduler, options: options) - .eraseToAnyPublisher() - } - - return Just(value) - .setFailureType(to: Failure.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - func tryFlatMap( maxPublishers: Subscribers.Demand = .unlimited, _ transform: @escaping (Self.Output) throws -> P diff --git a/SessionUtilitiesKit/Combine/ReplaySubject.swift b/SessionUtilitiesKit/Combine/ReplaySubject.swift index 428e58d9c..ad5f1efea 100644 --- a/SessionUtilitiesKit/Combine/ReplaySubject.swift +++ b/SessionUtilitiesKit/Combine/ReplaySubject.swift @@ -9,8 +9,7 @@ public final class ReplaySubject: Subject { private var buffer: [Output] = [Output]() private let bufferSize: Int private let lock: NSRecursiveLock = NSRecursiveLock() - - private var subscriptions = [ReplaySubjectSubscription]() + private var subscriptions: Atomic<[ReplaySubjectSubscription]> = Atomic([]) private var completion: Subscribers.Completion? // MARK: - Initialization @@ -27,7 +26,7 @@ public final class ReplaySubject: Subject { buffer.append(value) buffer = buffer.suffix(bufferSize) - subscriptions.forEach { $0.receive(value) } + subscriptions.wrappedValue.forEach { $0.receive(value) } } /// Sends a completion signal to the subscriber @@ -35,7 +34,7 @@ public final class ReplaySubject: Subject { lock.lock(); defer { lock.unlock() } self.completion = completion - subscriptions.forEach { subscription in subscription.receive(completion: completion) } + subscriptions.wrappedValue.forEach { $0.receive(completion: completion) } } /// Provides this Subject an opportunity to establish demand for any new upstream subscriptions @@ -61,11 +60,11 @@ public final class ReplaySubject: Subject { /// we can revert this change /// /// https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/20 - let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) { [buffer = buffer, completion = completion] subscription in + let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) { [weak self, buffer = buffer, completion = completion] subscription in + self?.subscriptions.mutate { $0.append(subscription) } subscription.replay(buffer, completion: completion) } subscriber.receive(subscription: subscription) - subscriptions.append(subscription) } } diff --git a/_SharedTestUtilities/CombineExtensions.swift b/_SharedTestUtilities/CombineExtensions.swift index f040a6008..057a2ddb3 100644 --- a/_SharedTestUtilities/CombineExtensions.swift +++ b/_SharedTestUtilities/CombineExtensions.swift @@ -7,8 +7,8 @@ import SessionUtilitiesKit public extension Publisher { func sinkAndStore(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable { self - .subscribe(on: DispatchQueue.main, immediatelyIfMain: true) - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .subscribe(on: ImmediateScheduler.shared) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { _ in } @@ -22,7 +22,7 @@ public extension AnyPublisher { var value: Output? _ = self - .receive(on: DispatchQueue.main, immediatelyIfMain: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { result in value = result } diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift index 0d3c55b78..b847a01dd 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -5,7 +5,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class MockGeneralCache: Mock, GeneralCacheType { +class MockGeneralCache: Mock, MutableGeneralCacheType { var encodedPublicKey: String? { get { return accept() as? String } set { accept(args: [newValue]) }