From 1e19fd0ce11edebd58537ae8586ab3d8713498e7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 26 Sep 2025 22:49:53 +1200 Subject: [PATCH 1/4] Create `BGContinuedProcessingTask` to upload media --- Sources/Jetpack/Info.plist | 1 + Sources/WordPress/Info.plist | 17 +- .../Classes/Services/MediaCoordinator.swift | 6 +- .../Media/MediaProgressCoordinator.swift | 7 + .../Media/MediaUploadBackgroundTracker.swift | 240 ++++++++++++++++++ 5 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift diff --git a/Sources/Jetpack/Info.plist b/Sources/Jetpack/Info.plist index 5f161d6e7013..eede01e0f66f 100644 --- a/Sources/Jetpack/Info.plist +++ b/Sources/Jetpack/Info.plist @@ -6,6 +6,7 @@ org.wordpress.bgtask.weeklyroundup org.wordpress.bgtask.weeklyroundup.processing + $(PRODUCT_BUNDLE_IDENTIFIER).mediaUpload CFBundleDevelopmentRegion en diff --git a/Sources/WordPress/Info.plist b/Sources/WordPress/Info.plist index b4cd495d06d0..b03780aa3bfa 100644 --- a/Sources/WordPress/Info.plist +++ b/Sources/WordPress/Info.plist @@ -6,6 +6,7 @@ org.wordpress.bgtask.weeklyroundup org.wordpress.bgtask.weeklyroundup.processing + $(PRODUCT_BUNDLE_IDENTIFIER).mediaUpload CFBundleDevelopmentRegion en @@ -191,13 +192,13 @@ UIPrerenderedIcon - Spectrum '22 + Spectrum '22 CFBundleIconFiles - spectrum-'22-icon-app-60x60 - spectrum-'22-icon-app-76x76 - spectrum-'22-icon-app-83.5x83.5 + spectrum-'22-icon-app-60x60 + spectrum-'22-icon-app-76x76 + spectrum-'22-icon-app-83.5x83.5 UIPrerenderedIcon @@ -406,13 +407,13 @@ UIPrerenderedIcon - Spectrum '22 + Spectrum '22 CFBundleIconFiles - spectrum-'22-icon-app-60x60 - spectrum-'22-icon-app-76x76 - spectrum-'22-icon-app-83.5x83.5 + spectrum-'22-icon-app-60x60 + spectrum-'22-icon-app-76x76 + spectrum-'22-icon-app-83.5x83.5 UIPrerenderedIcon diff --git a/WordPress/Classes/Services/MediaCoordinator.swift b/WordPress/Classes/Services/MediaCoordinator.swift index 443c69294e6c..54d7379685d4 100644 --- a/WordPress/Classes/Services/MediaCoordinator.swift +++ b/WordPress/Classes/Services/MediaCoordinator.swift @@ -142,7 +142,7 @@ class MediaCoordinator: NSObject { /// - parameter origin: The location in the app where the upload was initiated (optional). /// func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) { - addMedia(from: asset, blog: blog, post: nil, coordinator: mediaLibraryProgressCoordinator, analyticsInfo: analyticsInfo) + addMedia(from: asset, blog: blog, coordinator: mediaLibraryProgressCoordinator, analyticsInfo: analyticsInfo) } /// Adds the specified media asset to the specified post. The upload process @@ -192,14 +192,14 @@ class MediaCoordinator: NSObject { /// Create a `Media` instance and upload the asset to the Media Library. /// /// - SeeAlso: `MediaImportService.createMedia(with:blog:post:receiveUpdate:thumbnailCallback:completion:)` - private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) { + private func addMedia(from asset: ExportableAsset, blog: Blog, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) { coordinator.track(numberOfItems: 1) let service = MediaImportService(coreDataStack: coreDataStack) let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done) let creationProgress = service.createMedia( with: asset, blog: blog, - post: post, + post: nil, receiveUpdate: { [weak self] media in self?.processing(media) coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID) diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift index 9a6012fc3cba..1d8a8537785e 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift @@ -34,6 +34,8 @@ public class MediaProgressCoordinator: NSObject { private static var mediaProgressObserverContext = 0 + private lazy var bgTaskTracker: MediaUploadBackgroundTracker? = mediaUploadBackgroundTracker() + deinit { mediaGlobalProgress?.removeObserver(self, forKeyPath: #keyPath(Progress.fractionCompleted)) } @@ -72,6 +74,11 @@ public class MediaProgressCoordinator: NSObject { progress.setUserInfoObject(media, forKey: .mediaObject) mediaGlobalProgress?.addChild(progress, withPendingUnitCount: 1) mediaInProgress[mediaID] = progress + + let objectID = TaggedManagedObjectID(media) + Task { + await bgTaskTracker?.track(progress: progress, media: objectID) + } } /// Finish one of the tasks. diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift new file mode 100644 index 000000000000..26daae75d79d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift @@ -0,0 +1,240 @@ +import Foundation +import BackgroundTasks +import Combine +import WordPressShared + +// This protocol is used to hide the `@available(iOS 26.0, *)` check. +protocol MediaUploadBackgroundTracker { + + func track(progress: Progress, media: TaggedManagedObjectID) async + +} + +func mediaUploadBackgroundTracker() -> MediaUploadBackgroundTracker? { + if #available(iOS 26.0, *) { + ConcreteMediaUploadBackgroundTracker.shared + } else { + nil + } +} + +@available(iOS 26.0, *) +/// Utilize `BGContinuedProcessingTask` to show the uploading media activity. +private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker { + struct Item { + var media: TaggedManagedObjectID + var progress: Progress + } + + enum BGTaskState { + struct Accepted { + let task: BGContinuedProcessingTask + var items = [Item]() + var observers: [AnyCancellable] = [] + + init(task: BGContinuedProcessingTask) { + self.task = task + } + } + + // No uploading. No `BGContinuedProcessingTask`. + case idle + // Waiting for the OS to response to the creating `BGContinuedProcessingTask` request. + case pending([Item]) + // OS has created a `BGContinuedProcessingTask` instance. + case accepted(Accepted) + } + + // Since this type works with `BGTaskScheduler.shared`, it only makes sense for the type to also be a singleton. + static let shared = ConcreteMediaUploadBackgroundTracker() + + // We only use one `BGContinuedProcessingTask` for all uploads. When adding new media during uploading, the new ones + // will be added to the existing task. + private let taskId: String + + // State transtion: idle -> pending -> accepted -> [accepted...] -> idle. + private var state: BGTaskState = .idle + + private init?() { + let taskId = (Bundle.main.infoDictionary?["BGTaskSchedulerPermittedIdentifiers"] as? [String])?.first { + $0.hasSuffix(".mediaUpload") + } + guard let taskId else { + wpAssertionFailure("media upload task id not found in the Info.plist") + return nil + } + + self.taskId = taskId + BGTaskScheduler.shared.register(forTaskWithIdentifier: self.taskId, using: nil) { [weak self] task in + guard let task = task as? BGContinuedProcessingTask else { + wpAssertionFailure("Unexpected task instance") + return + } + + Task { + await self?.taskCreated(task) + } + } + } + + func track(progress: Progress, media: TaggedManagedObjectID) async { + let item = Item(media: media, progress: progress) + switch state { + case .idle: + state = .pending([item]) + + let request = BGContinuedProcessingTaskRequest(identifier: taskId, title: Strings.uploadingMediaTitle, subtitle: "") + request.strategy = .queue + do { + try BGTaskScheduler.shared.submit(request) + } catch { + DDLogError("Failed to submit a background task: \(error)") + } + case var .pending(items): + items.removeAll { + $0.media == media + } + items.append(item) + self.state = .pending(items) + case var .accepted(accepted): + observe(item, accepted: &accepted) + self.state = .accepted(accepted) + } + } + + private func taskCreated(_ task: BGContinuedProcessingTask) { + task.progress.totalUnitCount = 100 + task.expirationHandler = { [weak self] in + Task { + await self?.setTaskCompleted(success: false) + } + } + + var accepted = BGTaskState.Accepted(task: task) + switch state { + case .idle, .accepted: + wpAssertionFailure("Unexpected background task state") + case let .pending(items): + for item in items { + observe(item, accepted: &accepted) + } + } + + self.state = .accepted(accepted) + } + + private func observe(_ item: Item, accepted: inout BGTaskState.Accepted) { + accepted.items.append(item) + + let progress = item.progress.publisher(for: \.fractionCompleted).sink { [weak self] _ in + Task { + await self?.handleProgressUpdates() + } + } + accepted.observers.append(progress) + + Task { @MainActor in + guard let media = try? ContextManager.shared.mainContext.existingObject(with: item.media) else { return } + + let completion = media.publisher(for: \.remoteStatusNumber).sink { [weak self] _ in + Task { + await self?.handleStatusUpdates() + } + } + await self.addObserver(completion) + } + } + + private func addObserver(_ cancellable: AnyCancellable) { + guard case var .accepted(accepted) = state else { return } + accepted.observers.append(cancellable) + self.state = .accepted(accepted) + } + + private func handleProgressUpdates() { + guard case let .accepted(accepted) = state else { return } + + let fractionCompleted = accepted.items.map(\.progress.fractionCompleted).reduce(0, +) / Double(accepted.items.count) + accepted.task.progress.completedUnitCount = Int64(fractionCompleted * Double(accepted.task.progress.totalUnitCount)) + } + + private func handleStatusUpdates() async { + await updateMessaging() + await updateResult() + } + + @MainActor + private func updateMessaging() async { + guard case let .accepted(accepted) = await self.state else { return } + + let context = ContextManager.shared.mainContext + let mediaItems = accepted.items.compactMap { try? context.existingObject(with: $0.media) } + + let failed = mediaItems.count { $0.remoteStatus == .failed } + let success = mediaItems.count { $0.remoteStatus == .sync } + let total = mediaItems.count + + var subtitle = [String]() + if total - success - failed > 0 { + subtitle.append(String.localizedStringWithFormat(Strings.uploadingStatus, total - success - failed)) + } + if success > 0 { + subtitle.append(String.localizedStringWithFormat(Strings.successStatus, success)) + } + if failed > 0 { + subtitle.append(String.localizedStringWithFormat(Strings.failedStatus, failed)) + } + + accepted.task.updateTitle(Strings.uploadingMediaTitle, subtitle: ListFormatter.localizedString(byJoining: subtitle)) + } + + @MainActor + private func updateResult() async { + guard case let .accepted(accepted) = await self.state else { return } + + let context = ContextManager.shared.mainContext + let mediaItems = accepted.items.compactMap { try? context.existingObject(with: $0.media) } + + let completed = mediaItems.allSatisfy { $0.remoteStatus == .sync || $0.remoteStatus == .failed } + guard completed else { + return + } + + let success = mediaItems.allSatisfy { $0.remoteStatus == .sync } + await setTaskCompleted(success: success) + } + + private func setTaskCompleted(success: Bool) { + guard case let .accepted(accepted) = self.state else { return } + DDLogInfo("BGTask completed with success? \(success)") + + accepted.task.setTaskCompleted(success: success) + self.state = .idle + } +} + +private enum Strings { + static let uploadingMediaTitle = NSLocalizedString( + "BGTask.mediaUpload.title", + value: "Uploading media", + comment: "Title shown in background task when uploading media files" + ) + + static let uploadingStatus = NSLocalizedString( + "BGTask.mediaUpload.uploading", + value: "%1$d uploading", + comment: "Status message showing number of files currently uploading. %1$d is the count of uploading files." + ) + + static let successStatus = NSLocalizedString( + "BGTask.mediaUpload.successful", + value: "%1$d successful", + comment: "Status message showing number of files uploaded successfully. %1$d is the count of successful uploads." + ) + + static let failedStatus = NSLocalizedString( + "BGTask.mediaUpload.failed", + value: "%1$d failed", + comment: "Status message showing number of files that failed to upload. %1$d is the count of failed uploads." + ) +} From fcbba11ee4c60167d9d9272d25471770ff15d3fa Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 6 Oct 2025 11:42:22 +1300 Subject: [PATCH 2/4] Handle cancelling media uploads from Live Activity UI --- .../Media/MediaUploadBackgroundTracker.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift index 26daae75d79d..af589fd86c35 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift @@ -106,7 +106,7 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker task.progress.totalUnitCount = 100 task.expirationHandler = { [weak self] in Task { - await self?.setTaskCompleted(success: false) + await self?.handleExpiration() } } @@ -151,6 +151,20 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker self.state = .accepted(accepted) } + private func handleExpiration() { + if case let .accepted(accepted) = state { + Task { @MainActor in + let context = ContextManager.shared.mainContext + for item in accepted.items { + guard let media = try? context.existingObject(with: item.media) else { continue } + MediaCoordinator.shared.cancelUpload(of: media) + } + } + } + + setTaskCompleted(success: false) + } + private func handleProgressUpdates() { guard case let .accepted(accepted) = state else { return } From d5785da16ca397a2948ef848fb44c8c307e31cf7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 6 Oct 2025 11:59:28 +1300 Subject: [PATCH 3/4] Change optional init to non-optional --- .../Media/MediaUploadBackgroundTracker.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift index af589fd86c35..4b55b1d57c0a 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift @@ -55,14 +55,13 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker // State transtion: idle -> pending -> accepted -> [accepted...] -> idle. private var state: BGTaskState = .idle - private init?() { - let taskId = (Bundle.main.infoDictionary?["BGTaskSchedulerPermittedIdentifiers"] as? [String])?.first { - $0.hasSuffix(".mediaUpload") - } - guard let taskId else { - wpAssertionFailure("media upload task id not found in the Info.plist") - return nil - } + private init() { + let taskId = Bundle.main.bundleIdentifier! + ".mediaUpload" + + wpAssert( + (Bundle.main.infoDictionary?["BGTaskSchedulerPermittedIdentifiers"] as? [String])?.contains(taskId) == true, + "media upload task id not found in the Info.plist" + ) self.taskId = taskId BGTaskScheduler.shared.register(forTaskWithIdentifier: self.taskId, using: nil) { [weak self] task in From f174086d06364f1f75c36c078ed9c423ca9907e2 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 6 Oct 2025 13:59:49 +1300 Subject: [PATCH 4/4] Improve uploading status reporting --- .../Media/MediaUploadBackgroundTracker.swift | 113 +++++++++++++++--- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift index 4b55b1d57c0a..30b30e8d4227 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTracker.swift @@ -22,6 +22,7 @@ func mediaUploadBackgroundTracker() -> MediaUploadBackgroundTracker? { /// Utilize `BGContinuedProcessingTask` to show the uploading media activity. private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker { struct Item { + // Please note: all media query needs to be done in the main context, due to the current upload media implementation. var media: TaggedManagedObjectID var progress: Progress } @@ -55,6 +56,8 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker // State transtion: idle -> pending -> accepted -> [accepted...] -> idle. private var state: BGTaskState = .idle + private var coreDataChangesObserver: NSObjectProtocol? + private init() { let taskId = Bundle.main.bundleIdentifier! + ".mediaUpload" @@ -74,9 +77,12 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker await self?.taskCreated(task) } } + } func track(progress: Progress, media: TaggedManagedObjectID) async { + observeCoreDataChanges() + let item = Item(media: media, progress: progress) switch state { case .idle: @@ -101,6 +107,31 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker } } + private func observeCoreDataChanges() { + guard coreDataChangesObserver == nil else { return } + + coreDataChangesObserver = NotificationCenter.default.addObserver( + forName: .NSManagedObjectContextObjectsDidChange, + object: ContextManager.shared.mainContext, + queue: .main + ) { [weak self] notification in + let deleted = notification.userInfo?[NSManagedObjectContext.NotificationKey.deletedObjects.rawValue] as? Set ?? [] + + var mediaObjectIDs = Set>() + for object in deleted { + if let media = object as? Media { + mediaObjectIDs.insert(TaggedManagedObjectID(media)) + } + } + + if !mediaObjectIDs.isEmpty { + Task { + await self?.handleMediaObjectsUpdates(updated: mediaObjectIDs) + } + } + } + } + private func taskCreated(_ task: BGContinuedProcessingTask) { task.progress.totalUnitCount = 100 task.expirationHandler = { [weak self] in @@ -164,32 +195,51 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker setTaskCompleted(success: false) } - private func handleProgressUpdates() { + private func handleProgressUpdates() async { guard case let .accepted(accepted) = state else { return } - let fractionCompleted = accepted.items.map(\.progress.fractionCompleted).reduce(0, +) / Double(accepted.items.count) + let progresses = await MainActor.run { + let context = ContextManager.shared.mainContext + return accepted.items + .filter { item in + (try? context.existingObject(with: item.media)) != nil + } + .map(\.progress) + } + + let fractionCompleted = progresses.map(\.fractionCompleted).reduce(0, +) / Double(progresses.count) accepted.task.progress.completedUnitCount = Int64(fractionCompleted * Double(accepted.task.progress.totalUnitCount)) } + private func handleMediaObjectsUpdates(updated: Set>) async { + guard case let .accepted(accepted) = state else { return } + + let needsUpdate = accepted.items.contains(where: { updated.contains($0.media) }) + if needsUpdate { + await handleStatusUpdates() + } + } + private func handleStatusUpdates() async { await updateMessaging() await updateResult() } - @MainActor private func updateMessaging() async { - guard case let .accepted(accepted) = await self.state else { return } + guard case let .accepted(accepted) = self.state else { return } - let context = ContextManager.shared.mainContext - let mediaItems = accepted.items.compactMap { try? context.existingObject(with: $0.media) } + let statuses = await MainActor.run { + let context = ContextManager.shared.mainContext + return accepted.items.compactMap { try? context.existingObject(with: $0.media).uploadStatus } + } - let failed = mediaItems.count { $0.remoteStatus == .failed } - let success = mediaItems.count { $0.remoteStatus == .sync } - let total = mediaItems.count + let failed = statuses.count { $0 == .failure } + let success = statuses.count { $0 == .success} + let uploading = statuses.count { $0 == .uploading } var subtitle = [String]() - if total - success - failed > 0 { - subtitle.append(String.localizedStringWithFormat(Strings.uploadingStatus, total - success - failed)) + if uploading > 0 { + subtitle.append(String.localizedStringWithFormat(Strings.uploadingStatus, uploading)) } if success > 0 { subtitle.append(String.localizedStringWithFormat(Strings.successStatus, success)) @@ -201,20 +251,21 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker accepted.task.updateTitle(Strings.uploadingMediaTitle, subtitle: ListFormatter.localizedString(byJoining: subtitle)) } - @MainActor private func updateResult() async { - guard case let .accepted(accepted) = await self.state else { return } + guard case let .accepted(accepted) = self.state else { return } - let context = ContextManager.shared.mainContext - let mediaItems = accepted.items.compactMap { try? context.existingObject(with: $0.media) } + let mediaStatuses = await MainActor.run { + let context = ContextManager.shared.mainContext + return accepted.items.compactMap { try? context.existingObject(with: $0.media).uploadStatus } + } - let completed = mediaItems.allSatisfy { $0.remoteStatus == .sync || $0.remoteStatus == .failed } + let completed = mediaStatuses.allSatisfy { $0 == .success || $0 == .failure } guard completed else { return } - let success = mediaItems.allSatisfy { $0.remoteStatus == .sync } - await setTaskCompleted(success: success) + let success = mediaStatuses.allSatisfy { $0 == .success } + setTaskCompleted(success: success) } private func setTaskCompleted(success: Bool) { @@ -226,6 +277,32 @@ private actor ConcreteMediaUploadBackgroundTracker: MediaUploadBackgroundTracker } } +private enum MediaUploadStatus: Hashable { + case success + case failure + case uploading + case unknown +} + +private extension Media { + var uploadStatus: MediaUploadStatus { + if let number = remoteStatusNumber, let status = MediaRemoteStatus(rawValue: number.uintValue) { + switch status { + case .sync: + return .success + case .failed: + return .failure + case .pushing, .processing: + return .uploading + default: + return .unknown + } + } else { + return .unknown + } + } +} + private enum Strings { static let uploadingMediaTitle = NSLocalizedString( "BGTask.mediaUpload.title",