diff --git a/LibSession-Util b/LibSession-Util index 20c824029..9935bbe01 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit 20c82402971a20a2b0026558aced68790a6fc2a0 +Subproject commit 9935bbe0137423f39e3a2292268f180a043db94d diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 995d9ef01..b61697e0c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -874,6 +874,7 @@ FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */; }; FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; FDB7400D28EBEC240094D718 /* DateHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */; }; + FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBA8A832D59796F007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift */; }; FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */; }; FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */; }; FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBEE52D2B6A18B900C143A0 /* UserDefaultsConfig.swift */; }; @@ -2047,6 +2048,7 @@ FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PhotosUI.framework; path = System/Library/Frameworks/PhotosUI.framework; sourceTree = SDKROOT; }; FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Utilities.swift"; sourceTree = ""; }; FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHeaderCell.swift; sourceTree = ""; }; + FDBA8A832D59796F007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedGroupInvitesAndPromotionsJob.swift; sourceTree = ""; }; FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_AddJobPriority.swift; sourceTree = ""; }; FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = ""; }; FDBEE52D2B6A18B900C143A0 /* UserDefaultsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDefaultsConfig.swift; sourceTree = ""; }; @@ -3165,6 +3167,7 @@ FD22725E2C32911B004D8A6C /* ExpirationUpdateJob.swift */, FD22725A2C32911A004D8A6C /* FailedAttachmentDownloadsJob.swift */, FD2272562C32911A004D8A6C /* FailedMessageSendsJob.swift */, + FDBA8A832D59796F007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift */, FD2272662C32911B004D8A6C /* GarbageCollectionJob.swift */, FD2272672C32911B004D8A6C /* GetExpirationJob.swift */, FD2272622C32911B004D8A6C /* GroupInviteMemberJob.swift */, @@ -6260,6 +6263,7 @@ C38D5E8D2575011E00B6A65C /* MessageSender+LegacyClosedGroups.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, FDC13D492A16EC20007267C7 /* Service.swift in Sources */, + FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */, FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */, FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, @@ -7923,7 +7927,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 536; + CURRENT_PROJECT_VERSION = 537; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -7964,7 +7968,7 @@ MARKETING_VERSION = 2.9.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; - "OTHER_SWIFT_FLAGS[arch=*]" = "-D DEBUG"; + OTHER_SWIFT_FLAGS = "-D DEBUG"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; @@ -7999,7 +8003,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 536; + CURRENT_PROJECT_VERSION = 537; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8445,7 +8449,6 @@ OTHER_CFLAGS = ( "-fobjc-arc-exceptions", "-Werror=protocol", - "-fsanitize=address,undefined", ); OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9ddf9f6b3..6face867d 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { - "revision" : "b2b1d20a90b14d11f6ef4241da6b81c1d3f171e4", - "version" : "1.3.2" + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" } }, { diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 676982aad..5a3a8a244 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -847,7 +847,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa viewModel.threadData.threadVariant != updatedThreadData.threadVariant || viewModel.threadData.currentUserIsClosedGroupAdmin != updatedThreadData.currentUserIsClosedGroupAdmin { - legacyGroupsBanner.isHidden = (updatedThreadData.threadVariant != .legacyGroup) + legacyGroupsBanner.isHidden = ( + updatedThreadData.threadVariant != .legacyGroup || + !viewModel.dependencies[feature: .updatedGroups] + ) } if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { diff --git a/Session/Meta/asan_ignorelist.txt b/Session/Meta/asan_ignorelist.txt deleted file mode 100644 index cb71fae7d..000000000 --- a/Session/Meta/asan_ignorelist.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Exclude all code in the libwebp module from the Address Sanitiser (otherwise it won't build) -module:libwebp diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index a8803bd37..778b5c847 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -69,11 +69,25 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API .getExpiration: GetExpirationJob.self, .groupInviteMember: GroupInviteMemberJob.self, .groupPromoteMember: GroupPromoteMemberJob.self, - .processPendingGroupMemberRemovals: ProcessPendingGroupMemberRemovalsJob.self + .processPendingGroupMemberRemovals: ProcessPendingGroupMemberRemovalsJob.self, + .failedGroupInvitesAndPromotions: FailedGroupInvitesAndPromotionsJob.self ] executors.forEach { variant, executor in dependencies[singleton: .jobRunner].setExecutor(executor, for: variant) } + + // Register any recurring jobs to ensure they are actually scheduled + dependencies[singleton: .jobRunner].registerRecurringJobs( + scheduleInfo: [ + (.disappearingMessages, .recurringOnLaunch, true, false), + (.failedMessageSends, .recurringOnLaunch, true, false), + (.failedAttachmentDownloads, .recurringOnLaunch, true, false), + (.updateProfilePicture, .recurringOnActive, false, false), + (.retrieveDefaultOpenGroupRooms, .recurringOnActive, false, false), + (.garbageCollection, .recurringOnActive, false, false), + (.failedGroupInvitesAndPromotions, .recurringOnLaunch, true, false) + ] + ) } } diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index 686ece069..43da70e1b 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -184,7 +184,33 @@ public enum ConfigurationSyncJob: JobExecutor { case .finished: Log.info(.cat, "For \(swarmPublicKey) completed") case .failure(let error): Log.error(.cat, "For \(swarmPublicKey) failed due to error: \(error)") - failure(job, error, false) + + // If the failure is due to being offline then we should automatically + // retry if the connection is re-established + dependencies[cache: .libSessionNetwork].networkStatus + .first() + .sinkUntilComplete( + receiveValue: { status in + switch status { + // If we are currently connected then use the standard + // retry behaviour + case .connected: failure(job, error, false) + + // If not then wait until we are connected again before + // reporting the failure (which will result in the retry + // occurring once we reestablish a connection) + default: + dependencies[cache: .libSessionNetwork].networkStatus + .filter { $0 == .connected } + .first() + .sinkUntilComplete( + receiveCompletion: { _ in + failure(job, error, false) + } + ) + } + } + ) } }, receiveValue: { (configDumps: [ConfigDump]) in @@ -349,6 +375,7 @@ public extension ConfigurationSyncJob { guard let job: Job = Job( variant: .configurationSync, + behaviour: .recurring, threadId: swarmPublicKey, details: OptionalDetails(wasManualTrigger: true), transientData: AdditionalSequenceRequests( diff --git a/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift new file mode 100644 index 000000000..fa4206cb2 --- /dev/null +++ b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift @@ -0,0 +1,74 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("FailedGroupInvitesAndPromotionsJob", defaultLevel: .info) +} + +// MARK: - FailedGroupInvitesAndPromotionsJob + +public enum FailedGroupInvitesAndPromotionsJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + scheduler: S, + success: @escaping (Job, Bool) -> Void, + failure: @escaping (Job, Error, Bool) -> Void, + deferred: @escaping (Job) -> Void, + using dependencies: Dependencies + ) { + guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard !dependencies[cache: .libSession].isEmpty else { + return failure(job, JobRunnerError.missingRequiredDetails, false) + } + + var invitationsCount: Int = -1 + var promotionsCount: Int = -1 + + // Update all 'sending' message states to 'failed' + dependencies[singleton: .storage] + .writePublisher { db in + invitationsCount = try GroupMember + .filter( + GroupMember.Columns.groupId > SessionId.Prefix.group.rawValue && + GroupMember.Columns.groupId < SessionId.Prefix.group.endOfRangeString + ) + .filter(GroupMember.Columns.role == GroupMember.Role.standard) + .filter(GroupMember.Columns.roleStatus == GroupMember.RoleStatus.sending) + .updateAllAndConfig( + db, + GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.failed), + using: dependencies + ) + promotionsCount = try GroupMember + .filter( + GroupMember.Columns.groupId > SessionId.Prefix.group.rawValue && + GroupMember.Columns.groupId < SessionId.Prefix.group.endOfRangeString + ) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .filter(GroupMember.Columns.roleStatus == GroupMember.RoleStatus.sending) + .updateAllAndConfig( + db, + GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.failed), + using: dependencies + ) + } + .subscribe(on: scheduler, using: dependencies) + .receive(on: scheduler, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { _ in + Log.info(.cat, "Invites marked as failed: \(invitationsCount), Promotions marked as failed: \(promotionsCount)") + success(job, false) + } + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index f0f970420..17d2b6daf 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -814,7 +814,7 @@ extension MessageSender { groupSessionId: SessionId(.group, hex: groupSessionId), memberId: memberId, role: .standard, - status: .notSentYet, + status: .sending, profile: nil, using: dependencies ) diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index 44d1097bb..5c9956023 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -46,6 +46,14 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API self.maxFileSize = networkMaxFileSize self.localizedFormatted = localizedFormatted self.localizedDeformatted = localizedDeformatted + + // Register any recurring jobs to ensure they are actually scheduled + dependencies[singleton: .jobRunner].registerRecurringJobs( + scheduleInfo: [ + (.syncPushTokens, .recurringOnLaunch, false, false), + (.syncPushTokens, .recurringOnActive, false, true) + ] + ) } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 53afae102..1f9cd624f 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -151,6 +151,12 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// them if any exist - only one job can run at a time (if there is already a running job then any subsequent job will /// be deferred until it completes) case processPendingGroupMemberRemovals + + /// This is a job which checks for any pending group member invitations or promotions and marks them as failed + /// + /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from + /// running until it's complete + case failedGroupInvitesAndPromotions } public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable { diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 2275cceb9..5a6b3c112 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -673,6 +673,10 @@ open class Storage { _ operation: @escaping (Database) throws -> T, _ completion: ((Result) -> Void)? = nil ) -> Result { + let queryDbLock = NSLock() + var queryDb: Database? + let completionLock = NSLock() + var didComplete: Bool = false var result: Result = .failure(StorageError.invalidQueryResult) let semaphore: DispatchSemaphore? = (info.isAsync ? nil : DispatchSemaphore(value: 0)) let logErrorIfNeeded: (Result) -> () = { result in @@ -681,26 +685,46 @@ open class Storage { case .failure(let error): StorageState.logIfNeeded(error, isWrite: info.isWrite) } } + let completeOperation: (Result) -> Void = { operationResult in + completionLock.lock() + defer { completionLock.unlock() } + guard !didComplete else { return } + + /// If the query timed out then we should interrupt the query (don't want the query thread to remain blocked when we've + /// already handled it as a failure) + switch operationResult { + case .failure(let error) where error as? StorageError == StorageError.transactionDeadlockTimeout: + queryDbLock.lock() + defer { queryDbLock.unlock() } + queryDb?.interrupt() + + default: break + } + + didComplete = true + result = operationResult + semaphore?.signal() + + /// For async operations, log the error and call the completion closure + if info.isAsync { + logErrorIfNeeded(result) + completion?(result) + } + } /// Perform the actual operation switch (StorageState(info.storage), info.isWrite) { - case (.invalid(let error), _): - result = .failure(error) - semaphore?.signal() - + case (.invalid(let error), _): completeOperation(.failure(error)) case (.valid(let dbWriter), true): dbWriter.asyncWrite( - { db in result = .success(try Storage.track(db, info, operation)) }, - completion: { _, dbResult in - switch dbResult { - case .success: break - case .failure(let error): result = .failure(error) - } - semaphore?.signal() + { db in + queryDbLock.lock() + defer { queryDbLock.unlock() } - if info.isAsync { logErrorIfNeeded(result) } - completion?(result) - } + queryDb = db + return try Storage.track(db, info, operation) + }, + completion: { _, dbResult in completeOperation(dbResult) } ) case (.valid(let dbWriter), false): @@ -708,15 +732,16 @@ open class Storage { do { switch dbResult { case .failure(let error): throw error - case .success(let db): result = .success(try Storage.track(db, info, operation)) + case .success(let db): + queryDbLock.lock() + defer { queryDbLock.unlock() } + + queryDb = db + completeOperation(.success(try Storage.track(db, info, operation))) } } catch { - result = .failure(error) + completeOperation(.failure(error)) } - semaphore?.signal() - - if info.isAsync { logErrorIfNeeded(result) } - completion?(result) } } @@ -740,13 +765,13 @@ open class Storage { let timerQueue = DispatchQueue(label: "org.session.debugSemaphoreTimer", qos: .userInteractive) let timer = DispatchSource.makeTimerSource(queue: timerQueue) var iterations: UInt64 = 0 + + /// Every tick of the timer check if the semaphore has completed or we have timed out timer.schedule(deadline: .now(), repeating: .milliseconds(100)) - timer.setEventHandler { iterations += 1 - semaphoreResult = semaphore?.wait(timeout: .now()) // Get the result from the original semaphore - if semaphoreResult == .success || iterations >= 50 { + if iterations >= 50 || semaphore?.wait(timeout: .now()) == .success { timer.cancel() timerSemaphore.signal() } @@ -754,18 +779,18 @@ open class Storage { timer.resume() timerSemaphore.wait() // Wait indefinitely for the timer semaphore + semaphoreResult = (iterations >= 50 ? .timedOut : .success) } #else semaphoreResult = semaphore?.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) #endif - /// 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 the transaction timed out then log the error and report a failure, otherwise handle whatever the result was + completeOperation(semaphoreResult != .timedOut ? + result : + .failure(StorageError.transactionDeadlockTimeout) + ) - if !info.isAsync { logErrorIfNeeded(result) } return result } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 75d31eb59..48d900246 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -53,6 +53,9 @@ public protocol JobRunnerType: AnyObject { func manuallyTriggerResult(_ job: Job?, result: JobRunner.JobResult) func afterJob(_ job: Job?, state: JobRunner.JobState) -> AnyPublisher func removePendingJob(_ job: Job?) + + func registerRecurringJobs(scheduleInfo: [JobRunner.ScheduleInfo]) + func scheduleRecurringJobsIfNeeded() } // MARK: - JobRunnerType Convenience @@ -213,6 +216,13 @@ public final class JobRunner: JobRunnerType { } } + public typealias ScheduleInfo = ( + variant: Job.Variant, + behaviour: Job.Behaviour, + shouldBlock: Bool, + shouldSkipLaunchBecomeActive: Bool + ) + private enum Validation { case enqueueOnly case persist @@ -225,6 +235,7 @@ public final class JobRunner: JobRunnerType { @ThreadSafeObject private var blockingQueue: JobQueue? @ThreadSafeObject private var queues: [Job.Variant: JobQueue] @ThreadSafeObject private var blockingQueueDrainCallback: [() -> ()] = [] + @ThreadSafeObject private var registeredRecurringJobs: [JobRunner.ScheduleInfo] = [] @ThreadSafe internal var appReadyToStartQueues: Bool = false @ThreadSafe internal var appHasBecomeActive: Bool = false @@ -879,6 +890,55 @@ public final class JobRunner: JobRunnerType { queues[job.variant]?.removePendingJob(jobId) } + public func registerRecurringJobs(scheduleInfo: [JobRunner.ScheduleInfo]) { + _registeredRecurringJobs.performUpdate { $0.appending(contentsOf: scheduleInfo) } + } + + public func scheduleRecurringJobsIfNeeded() { + let scheduleInfo: [ScheduleInfo] = registeredRecurringJobs + let variants: Set = Set(scheduleInfo.map { $0.variant }) + let maybeExistingJobs: [Job]? = dependencies[singleton: .storage].read { db in + try Job + .filter(variants.contains(Job.Columns.variant)) + .fetchAll(db) + } + + guard let existingJobs: [Job] = maybeExistingJobs else { + Log.warn(.jobRunner, "Failed to load existing recurring jobs from the database") + return + } + + let missingScheduledJobs: [ScheduleInfo] = scheduleInfo + .filter { scheduleInfo in + !existingJobs.contains { existingJob in + existingJob.variant == scheduleInfo.variant && + existingJob.behaviour == scheduleInfo.behaviour && + existingJob.shouldBlock == scheduleInfo.shouldBlock && + existingJob.shouldSkipLaunchBecomeActive == scheduleInfo.shouldSkipLaunchBecomeActive + } + } + + guard !missingScheduledJobs.isEmpty else { return } + + var numScheduledJobs: Int = 0 + dependencies[singleton: .storage].write { db in + try missingScheduledJobs.forEach { variant, behaviour, shouldBlock, shouldSkipLaunchBecomeActive in + _ = try Job( + variant: variant, + behaviour: behaviour, + shouldBlock: shouldBlock, + shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive + ).inserted(db) + numScheduledJobs += 1 + } + } + + switch numScheduledJobs == missingScheduledJobs.count { + case true: Log.info(.jobRunner, "Scheduled \(numScheduledJobs) missing recurring job(s)") + case false: Log.error(.jobRunner, "Failed to schedule \(missingScheduledJobs.count - numScheduledJobs) recurring job(s)") + } + } + // MARK: - Convenience fileprivate static func getRetryInterval(for job: Job) -> TimeInterval { diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 20f2f63df..65c084de0 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -92,6 +92,10 @@ public enum AppSetup { } }, onComplete: { result in + // Now that the migrations are complete we need to ensure any recurring jobs are + // properly scheduled + dependencies[singleton: .jobRunner].scheduleRecurringJobsIfNeeded() + // Callback that the migrations have completed migrationsCompletion(result)