diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 23d71d319..68f6d80d8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -7919,7 +7919,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 533; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -7995,7 +7995,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 533; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 84e2b8399..dd61d491f 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -562,8 +562,8 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl let viewController = ModalActivityIndicatorViewController() { modalActivityIndicator in SnodeAPI .getSessionID(for: inviteByIdValue, using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -620,6 +620,8 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl allowAccessToHistoricMessages: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite], using: dependencies ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] result in modalActivityIndicator.dismiss { @@ -691,6 +693,8 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl memberIds: memberIds, using: dependencies ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] result in modalActivityIndicator.dismiss { @@ -840,8 +844,8 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl using: dependencies ) .eraseToAnyPublisher() - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] result in modalActivityIndicator.dismiss(completion: { diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 5b0319c0d..d464d1ccb 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1002,71 +1002,69 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob _ memberInfo: [(id: String, profile: Profile?)], isRetry: Bool ) { - let viewController = ModalActivityIndicatorViewController(canCancel: false) { [dependencies, threadId] modalActivityIndicator in - MessageSender - .promoteGroupMembers( - groupSessionId: SessionId(.group, hex: threadId), - members: memberInfo, - isRetry: isRetry, - using: dependencies - ) - .sinkUntilComplete( - receiveCompletion: { result in - modalActivityIndicator.dismiss { - switch result { - case .failure: - viewModel?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "promotionFailed" - .putNumber(memberInfo.count) - .localized(), - body: .text("promotionFailedDescription" - .putNumber(memberInfo.count) - .localized()), - confirmTitle: "yes".localized(), - cancelTitle: "cancel".localized(), - cancelStyle: .alert_text, - dismissOnConfirm: false, - onConfirm: { modal in - modal.dismiss(animated: true) { - send(viewModel, memberInfo, isRetry: isRetry) - } - }, - onCancel: { modal in - /// Flag the members as failed - let memberIds: [String] = memberInfo.map(\.id) - dependencies[singleton: .storage].writeAsync { db in - try? GroupMember - .filter(GroupMember.Columns.groupId == threadId) - .filter(memberIds.contains(GroupMember.Columns.profileId)) - .updateAllAndConfig( - db, - GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.failed), - using: dependencies - ) - } - modal.dismiss(animated: true) - } - ) - ), - transitionType: .present - ) - - case .finished: - /// Show a toast that we have sent the promotions - viewModel?.showToast( - text: "adminSendingPromotion" + MessageSender + .promoteGroupMembers( + groupSessionId: SessionId(.group, hex: threadId), + members: memberInfo, + isRetry: isRetry, + using: dependencies + ) + .showingBlockingLoading(in: self.navigatableState) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { [threadId, dependencies] result in + switch result { + case .failure: + viewModel?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "promotionFailed" .putNumber(memberInfo.count) .localized(), - backgroundColor: .backgroundSecondary + body: .text("promotionFailedDescription" + .putNumber(memberInfo.count) + .localized()), + confirmTitle: "yes".localized(), + cancelTitle: "cancel".localized(), + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { modal in + modal.dismiss(animated: true) { + send(viewModel, memberInfo, isRetry: isRetry) + } + }, + onCancel: { modal in + /// Flag the members as failed + let memberIds: [String] = memberInfo.map(\.id) + dependencies[singleton: .storage].writeAsync { db in + try? GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(memberIds.contains(GroupMember.Columns.profileId)) + .updateAllAndConfig( + db, + GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.failed), + using: dependencies + ) + } + modal.dismiss(animated: true) + } ) - } - } + ), + transitionType: .present + ) + + case .finished: + /// Show a toast that we have sent the promotions + viewModel?.showToast( + text: "adminSendingPromotion" + .putNumber(memberInfo.count) + .localized(), + backgroundColor: .backgroundSecondary + ) } - ) - } - viewModel?.transitionToScreen(viewController, transitionType: .present) + } + ) } /// Show the selection list @@ -1317,6 +1315,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob groupDescription: finalDescription, using: dependencies ) + .showingBlockingLoading(in: self?.navigatableState) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] result in switch result { @@ -1423,90 +1424,91 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob default: break } - func performChanges(_ viewController: ModalActivityIndicatorViewController, _ displayPictureUpdate: DisplayPictureManager.Update) { - let existingFileName: String? = dependencies[singleton: .storage].read { [threadId] db in - try? ClosedGroup - .filter(id: threadId) - .select(.displayPictureFilename) - .asRequest(of: String.self) - .fetchOne(db) + Just(displayPictureUpdate) + .setFailureType(to: Error.self) + .flatMap { [dependencies] update -> AnyPublisher in + switch displayPictureUpdate { + case .none, .currentUserRemove, .currentUserUploadImageData, .currentUserUpdateTo, + .contactRemove, .contactUpdateTo: + return Fail(error: AttachmentError.invalidStartState).eraseToAnyPublisher() + + case .groupRemove, .groupUpdateTo: + return Just(displayPictureUpdate) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + case .groupUploadImageData(let data): + return dependencies[singleton: .displayPictureManager] + .prepareAndUploadDisplayPicture(imageData: data) + .map { url, fileName, key -> DisplayPictureManager.Update in + .groupUpdateTo(url: url, key: key, fileName: fileName) + } + .mapError { $0 as Error } + .eraseToAnyPublisher() + } } - - MessageSender - .updateGroup( - groupSessionId: threadId, - displayPictureUpdate: displayPictureUpdate, - using: dependencies + .flatMapStorageReadPublisher(using: dependencies) { [threadId] db, displayPictureUpdate -> (DisplayPictureManager.Update, String?) in + ( + displayPictureUpdate, + try? ClosedGroup + .filter(id: threadId) + .select(.displayPictureFilename) + .asRequest(of: String.self) + .fetchOne(db) ) - .sinkUntilComplete( - receiveCompletion: { [dependencies] result in - // Remove any cached avatar image value - if let existingFileName: String = existingFileName { - dependencies.mutate(cache: .displayPicture) { $0.imageData[existingFileName] = nil } - } - - DispatchQueue.main.async { - viewController.dismiss(completion: { - onComplete() - }) - } + } + .flatMap { [threadId, dependencies] displayPictureUpdate, existingFileName -> AnyPublisher in + MessageSender + .updateGroup( + groupSessionId: threadId, + displayPictureUpdate: displayPictureUpdate, + using: dependencies + ) + .map { _ in existingFileName } + .eraseToAnyPublisher() + } + .handleEvents( + receiveOutput: { [dependencies] existingFileName in + // Remove any cached avatar image value + if let existingFileName: String = existingFileName { + dependencies.mutate(cache: .displayPicture) { $0.imageData[existingFileName] = nil } } - ) - } - - let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] viewController in - switch displayPictureUpdate { - case .none, .currentUserRemove, .currentUserUploadImageData, .currentUserUpdateTo, - .contactRemove, .contactUpdateTo: - viewController.dismiss(animated: true) // Shouldn't get called - - case .groupRemove, .groupUpdateTo: performChanges(viewController, displayPictureUpdate) - case .groupUploadImageData(let data): - dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data) - .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - viewController.dismiss { - let message: String = { - switch (displayPictureUpdate, error) { - case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, .uploadMaxFileSizeExceeded): - return "profileDisplayPictureSizeError".localized() - - default: return "errorConnection".localized() - } - }() - - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), - body: .text(message), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - dismissType: .single - ) - ), - transitionType: .present - ) - } + } + ) + .showingBlockingLoading(in: self.navigatableState) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .failure(let error): + let message: String = { + switch (displayPictureUpdate, error) { + case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() + case (_, DisplayPictureError.uploadMaxFileSizeExceeded): + return "profileDisplayPictureSizeError".localized() + + default: return "errorConnection".localized() } - }, - receiveValue: { url, fileName, key in - performChanges( - viewController, - .groupUpdateTo(url: url, key: key, fileName: fileName) - ) - } - ) - } - } - self.transitionToScreen(viewController, transitionType: .present) + }() + + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), + body: .text(message), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text, + dismissType: .single + ) + ), + transitionType: .present + ) + + case .finished: onComplete() + } + } + ) } private func updateBlockedState( diff --git a/SessionMessagingKit/Database/Models/ConfigDump.swift b/SessionMessagingKit/Database/Models/ConfigDump.swift index 1bd5d2e45..fa65ec826 100644 --- a/SessionMessagingKit/Database/Models/ConfigDump.swift +++ b/SessionMessagingKit/Database/Models/ConfigDump.swift @@ -120,10 +120,15 @@ public extension ConfigDump.Variant { /// This value defines the order that the ConfigDump records should be loaded in, we need to load the `groupKeys` /// config _after_ the `groupInfo` and `groupMembers` configs as it requires those to be passed as arguments + /// + /// We also may as well load the user configs first (shouldn't make a difference but makes things easier to debug when + /// the user configs are loaded first var loadOrder: Int { switch self { - case .groupKeys: return 1 - default: return 0 + case .invalid: return 3 + case .groupKeys: return 2 + case .groupInfo, .groupMembers: return 1 + case .userProfile, .contacts, .convoInfoVolatile, .userGroups: return 0 } } diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index f7d2d8eaf..27f26b1ec 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -701,7 +701,10 @@ public extension ConfirmationModal.Info { func isValid(with info: ConfirmationModal.Info) -> Bool { boolValue } } + /// The `AfterChangeValidator` will also return `false` for the initial validity check and will use the provided + /// value for subsequent checks class AfterChangeValidator: ButtonValidator { + private(set) var hasDoneInitialValidCheck: Bool = false let isValid: (ConfirmationModal.Info) -> Bool required public init(booleanLiteral value: BooleanLiteralType) { @@ -717,7 +720,14 @@ public extension ConfirmationModal.Info { super.init(booleanLiteral: false) } - public override func isValid(with info: ConfirmationModal.Info) -> Bool { return self.isValid(info) } + public override func isValid(with info: ConfirmationModal.Info) -> Bool { + guard hasDoneInitialValidCheck else { + hasDoneInitialValidCheck = true + return false + } + + return self.isValid(info) + } } // MARK: - ShowCondition diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 4a9001b19..bd095a590 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -48,6 +48,9 @@ open class Storage { /// When attempting to do a write the transaction will wait this long to acquite a lock before failing private static let writeTransactionStartTimeout: TimeInterval = 5 + /// If a transaction takes longer than this duration then we should fail the transaction rather than keep hanging + private static let transactionDeadlockTimeoutSeconds: Int = 5 + private static var sharedDatabaseDirectoryPath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/database" } private static var databasePath: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" } private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" } @@ -376,7 +379,7 @@ open class Storage { // Note: The non-async migration should only be used for unit tests guard async else { return migrationCompleted(Result(catching: { try migrator.migrate(dbWriter) })) } - migrator.asyncMigrate(dbWriter) { result in + migrator.asyncMigrate(dbWriter) { [dependencies] result in let finalResult: Result = { switch result { case .failure(let error): return .failure(error) @@ -384,7 +387,11 @@ open class Storage { } }() - migrationCompleted(finalResult) + // Note: We need to dispatch this to the next run toop to prevent blocking if the callback + // performs subsequent database operations + DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { + migrationCompleted(finalResult) + } } } @@ -670,7 +677,10 @@ open class Storage { /// Perform the actual operation switch (StorageState(info.storage), info.isWrite) { - case (.invalid(let error), _): result = .failure(error) + case (.invalid(let error), _): + result = .failure(error) + semaphore?.signal() + case (.valid(let dbWriter), true): dbWriter.asyncWrite( { db in result = .success(try Storage.track(db, info, operation)) }, @@ -705,7 +715,13 @@ open class Storage { /// If this is a synchronous operation then `semaphore` will exist and will block here waiting on the signal from one of the /// above closures to be sent - semaphore?.wait() + let semaphoreResult: DispatchTimeoutResult? = semaphore?.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) + + /// If the transaction timed out then log the error and report a failure + guard semaphoreResult != .timedOut else { + StorageState.logIfNeeded(StorageError.transactionDeadlockTimeout, isWrite: info.isWrite) + return .failure(StorageError.transactionDeadlockTimeout) + } if !info.isAsync { logErrorIfNeeded(result) } return result diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift index 3e3f7ba20..31981be8d 100644 --- a/SessionUtilitiesKit/Database/StorageError.swift +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -14,6 +14,7 @@ public enum StorageError: Error { case keySpecInaccessible case decodingFailed case invalidQueryResult + case transactionDeadlockTimeout case failedToSave case objectNotFound