diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index 677ed142..691bb2e9 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; }; + B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* GopeedTaskEvent.swift */; }; 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -102,6 +103,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = ""; }; 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = ""; }; + B30000000000000000000002 /* GopeedTaskEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedTaskEvent.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -286,6 +288,7 @@ 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, + B30000000000000000000002 /* GopeedTaskEvent.swift */, ); path = Runner; sourceTree = ""; @@ -607,6 +610,7 @@ 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, + B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift index bc8f65a9..39453e06 100644 --- a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift +++ b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift @@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject { // MARK: - BGCPT state - private var registeredIdentifiers = Set() private var pendingTaskIDs = Set() + private var foregroundStartTokens: [String: UUID] = [:] private struct ExpiringTaskState { let identifier: String let generation: UUID @@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject { // Pre-mark task.start so the Objective-C forwarder can // immediately suppress the custom ActivityKit Live Activity. if - let data = payload.data(using: .utf8), - let json = try? JSONSerialization.jsonObject( - with: data - ) as? [String: Any], - let type = json["type"] as? String, - type == "task.start", - let taskID = json["taskId"] as? String + let event = GopeedTaskEvent.decode(payload), + event.type == .start { setTaskOwnership( - taskID: taskID, + taskID: event.taskID, handled: true ) } @@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject { return } - guard - let data = payload.data( - using: .utf8 - ), - let json = - try? JSONSerialization - .jsonObject( - with: data - ) as? [String: Any], - let type = - json["type"] as? String, - let taskID = - json["taskId"] as? String - else { + guard let event = GopeedTaskEvent.decode(payload) else { print( "ContinuedProcessing: invalid event" ) return } - let name = - json["name"] as? String - ?? "Download" + let taskID = event.taskID + let name = event.name - switch type { + switch event.type { - case "task.start": + case .start: progressRecoveryTokens.removeValue( forKey: taskID ) @@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject { name: name ) - case "task.progress": + case .progress: if let generation = taskGenerations[taskID] { updateProgress( @@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject { ) } - case "task.pause": + case .pause: if let expiring = expiringTasks[taskID] { finishExpirationCleanup( @@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject { ) } - case "task.done": + case .done: finishTask( taskID: taskID, success: true, @@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject { "Download complete" ) - case "task.error": + case .error: finishTask( taskID: taskID, success: false, @@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject { "Download failed" ) - case "task.delete": + case .delete: finishTask( taskID: taskID, success: true, @@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject { "Download removed" ) - default: + case .other: break } } - // MARK: - Recover active task after setting changes private func recoverTaskFromProgress( @@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject { !pendingTaskIDs.contains(taskID), activeTasks[taskID] == nil, expiringTasks[taskID] == nil, + foregroundStartTokens[taskID] == nil, progressRecoveryTokens[taskID] == nil else { return @@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject { guard !pendingTaskIDs.contains(taskID), - activeTasks[taskID] == nil + activeTasks[taskID] == nil, + foregroundStartTokens[taskID] == nil else { setTaskOwnership( taskID: taskID, @@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject { return } - // BGCPT should originate from a foreground user action. - // This main-thread check happens only once per task start. - let appIsActive: Bool + // BGCPT must originate from a foreground user action. + // Never synchronously hop from workerQueue to the main queue: + // doing so can deadlock if the main thread is waiting on work + // that eventually needs workerQueue. + let foregroundToken = UUID() + foregroundStartTokens[taskID] = + foregroundToken - if Thread.isMainThread { - appIsActive = + DispatchQueue.main.async { [weak self] in + let appIsActive = UIApplication.shared .applicationState == .active - } else { - appIsActive = - DispatchQueue.main.sync { - UIApplication.shared - .applicationState == .active - } + + guard let self else { + return + } + + self.workerQueue.async { + self.continueBeginTaskAfterForegroundCheck( + taskID: taskID, + name: name, + foregroundToken: foregroundToken, + appIsActive: appIsActive + ) + } + } + } + + private func continueBeginTaskAfterForegroundCheck( + taskID: String, + name: String, + foregroundToken: UUID, + appIsActive: Bool + ) { + guard + foregroundStartTokens[taskID] + == foregroundToken + else { + return + } + + foregroundStartTokens.removeValue( + forKey: taskID + ) + + guard currentEnabledSnapshot() else { + setTaskOwnership( + taskID: taskID, + handled: false + ) + return } guard appIsActive else { @@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject { return } + // Re-check after the asynchronous main-thread hop. A terminal + // event, disable, or another valid start may have changed the + // lifecycle while the foreground state was being read. + guard + !pendingTaskIDs.contains(taskID), + activeTasks[taskID] == nil + else { + setTaskOwnership( + taskID: taskID, + handled: true + ) + return + } + let generation = UUID() taskGenerations[taskID] = generation @@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject { return } - registeredIdentifiers.insert(identifier) - let request = BGContinuedProcessingTaskRequest( identifier: identifier, @@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject { } } - // MARK: - System launched task private func activateTask( @@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject { success: Bool, finalSubtitle: String ) { + foregroundStartTokens.removeValue( + forKey: taskID + ) + progressRecoveryTokens.removeValue( forKey: taskID ) @@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject { Set( handledTaskIDsSnapshot() + Array(taskIdentifiers.keys) + + Array(foregroundStartTokens.keys) + Array(pendingTaskIDs) + Array(activeTasks.keys) + Array(expiringTasks.keys) @@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject { } activeTasks.removeAll() + foregroundStartTokens.removeAll() pendingTaskIDs.removeAll() expiringTasks.removeAll() taskGenerations.removeAll() diff --git a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift index 6889dec9..1fc9fa65 100644 --- a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift +++ b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift @@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject { return } - guard - let data = payload.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) - as? [String: Any], - let type = json["type"] as? String, - let taskID = json["taskId"] as? String - else { + guard let event = GopeedTaskEvent.decode(payload) else { print("LiveActivity: invalid task event payload") return } + let taskID = event.taskID + guard !isTaskSuppressed(taskID) else { return } - let name = json["name"] as? String ?? "Download" + let name = event.name - switch type { + switch event.type { - case "task.start": + case .start: let generation = startGeneration( taskID: taskID @@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject { ) } - case "task.progress": + case .progress: guard let generation = generationForProgress( @@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject { generation: generation ) - case "task.pause": + case .pause: if let generation = invalidateGeneration( taskID: taskID @@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.done": + case .done: if let generation = invalidateGeneration( taskID: taskID @@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.error": + case .error: let error = - json["error"] as? String + event.error ?? "Download failed" if let generation = @@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.delete": + case .delete: if let generation = invalidateGeneration( taskID: taskID @@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject { } } - default: + case .other: break } } diff --git a/ui/flutter/ios/Runner/GopeedTaskEvent.swift b/ui/flutter/ios/Runner/GopeedTaskEvent.swift new file mode 100644 index 00000000..613435d8 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedTaskEvent.swift @@ -0,0 +1,69 @@ +import Foundation + +enum GopeedTaskEventType: Equatable { + case start + case progress + case pause + case done + case error + case delete + case other(String) + + init(rawValue: String) { + switch rawValue { + case "task.start": + self = .start + case "task.progress": + self = .progress + case "task.pause": + self = .pause + case "task.done": + self = .done + case "task.error": + self = .error + case "task.delete": + self = .delete + default: + self = .other(rawValue) + } + } +} + +struct GopeedTaskEvent { + let type: GopeedTaskEventType + let taskID: String + let name: String + let error: String? + + private struct Payload: Decodable { + let type: String + let taskId: String + let name: String? + let error: String? + } + + static func decode( + _ payload: String + ) -> GopeedTaskEvent? { + guard + let data = payload.data(using: .utf8), + let decoded = + try? JSONDecoder().decode( + Payload.self, + from: data + ) + else { + return nil + } + + return GopeedTaskEvent( + type: + GopeedTaskEventType( + rawValue: decoded.type + ), + taskID: decoded.taskId, + name: decoded.name ?? "Download", + error: decoded.error + ) + } +}