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..ee76f908eaa4 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) @@ -453,6 +453,10 @@ class MediaCoordinator: NSObject { WPAppAnalytics.track(event, properties: properties, blog: media.blog) } + func submitBackgroundUploadTask() { + mediaLibraryProgressCoordinator.submitBackgroundUploadTask() + } + // MARK: - Progress /// - returns: The current progress for the specified media object. diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 43ab1ff29676..40c54b6132fc 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -193,6 +193,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { public func applicationWillResignActive(_ application: UIApplication) { DDLogInfo("\(self) \(#function)") + MediaCoordinator.shared.submitBackgroundUploadTask() } public func applicationDidBecomeActive(_ application: UIApplication) { diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift index 9a6012fc3cba..6f67030d8e60 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift @@ -74,6 +74,21 @@ public class MediaProgressCoordinator: NSObject { mediaInProgress[mediaID] = progress } + /// Utilize BGContinuedProcessingTask to show upload progress and extend the allowed background time for the upload. + /// Note: This function needs to be called before the app goes to the background. + func submitBackgroundUploadTask() { + guard let scheduler = mediaUploadBackgroundTaskScheduler() else { return } + + for (mediaID, progress) in mediaInProgress { + guard let media = media(withIdentifier: mediaID) else { + continue + } + if media.remoteStatus == .pushing || media.remoteStatus == .processing { + scheduler.scheduleTask(for: TaggedManagedObjectID(media), progress: progress) + } + } + } + /// Finish one of the tasks. /// /// Note: This method is used to advance the completed number of tasks, when the task doesn't have any relevant associated work/progress to be tracked. diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTaskScheduler.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTaskScheduler.swift new file mode 100644 index 000000000000..9fa1fff76d9f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaUploadBackgroundTaskScheduler.swift @@ -0,0 +1,327 @@ +import Foundation +import BackgroundTasks +import Combine +import WordPressShared + +// This protocol is used to hide the `@available(iOS 26.0, *)` check. +protocol MediaUploadBackgroundTaskScheduler { + + /// Create a `BGContinuedProcessingTask` to get extra background time for the media uploading. + /// + /// Note: This method must be called from the main thread. + func scheduleTask(for media: TaggedManagedObjectID, progress: Progress) + +} + +func mediaUploadBackgroundTaskScheduler() -> MediaUploadBackgroundTaskScheduler? { + if #available(iOS 26.0, *) { + ConcreteMediaUploadBackgroundTaskScheduler.shared + } else { + nil + } +} + +/// Utilize `BGContinuedProcessingTask` to show the uploading media activity. +/// +/// Due to how media uploading is implemented currently, we need to read the uploading state from the main thread. +/// For better code readability, this type is implemented in the same way where everything runs on the main thread. +@available(iOS 26.0, *) +private class ConcreteMediaUploadBackgroundTaskScheduler: MediaUploadBackgroundTaskScheduler { + 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 + } + + 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 = ConcreteMediaUploadBackgroundTaskScheduler() + + // 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 var coreDataChangesObserver: NSObjectProtocol? + + private init() { + // `Bundle.main.bundleIdentifier` should never be nil, but we'll use a hard-coded fallback just in case. + let taskId = (Bundle.main.bundleIdentifier ?? "org.wordpress") + ".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: DispatchQueue.main) { [weak self] task in + guard let task = task as? BGContinuedProcessingTask else { + wpAssertionFailure("Unexpected task instance") + return + } + + self?.taskCreated(task) + } + } + + func scheduleTask(for media: TaggedManagedObjectID, progress: Progress) { + wpAssert(Thread.isMainThread) + + observeCoreDataChanges() + + 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 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 { @MainActor in + self?.handleMediaObjectsUpdates(updated: mediaObjectIDs) + } + } + } + } + + private func taskCreated(_ task: BGContinuedProcessingTask) { + task.progress.totalUnitCount = 100 + task.expirationHandler = { [weak self] in + self?.handleExpiration() + } + + 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) + + // Immediately update the `BGContinuedProcessingTask` instance with the current media uploading status. + self.handleProgressUpdates() + self.handleStatusUpdates() + } + + private func observe(_ item: Item, accepted: inout BGTaskState.Accepted) { + accepted.items.append(item) + + let progress = item.progress + .publisher(for: \.fractionCompleted) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleProgressUpdates() + } + accepted.observers.append(progress) + + guard let media = try? ContextManager.shared.mainContext.existingObject(with: item.media) else { return } + + let completion = media.publisher(for: \.remoteStatusNumber).sink { [weak self] _ in + self?.handleStatusUpdates() + } + accepted.observers.append(completion) + } + + private func addObserver(_ cancellable: AnyCancellable) { + guard case var .accepted(accepted) = state else { return } + accepted.observers.append(cancellable) + self.state = .accepted(accepted) + } + + private func handleExpiration() { + if case let .accepted(accepted) = state { + 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 } + + let context = ContextManager.shared.mainContext + let progresses = 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>) { + guard case let .accepted(accepted) = state else { return } + + let needsUpdate = accepted.items.contains(where: { updated.contains($0.media) }) + if needsUpdate { + handleStatusUpdates() + } + } + + private func handleStatusUpdates() { + updateMessaging() + updateResult() + } + + private func updateMessaging() { + guard case let .accepted(accepted) = self.state else { return } + + let context = ContextManager.shared.mainContext + let statuses = accepted.items.compactMap { try? context.existingObject(with: $0.media).uploadStatus } + + let failed = statuses.count { $0 == .failure } + let success = statuses.count { $0 == .success} + let uploading = statuses.count { $0 == .uploading } + + var subtitle = [String]() + if uploading > 0 { + subtitle.append(String.localizedStringWithFormat(Strings.uploadingStatus, uploading)) + } + 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)) + } + + private func updateResult() { + guard case let .accepted(accepted) = self.state else { return } + + let context = ContextManager.shared.mainContext + let mediaStatuses = accepted.items.compactMap { try? context.existingObject(with: $0.media).uploadStatus } + + let completed = mediaStatuses.allSatisfy { $0 == .success || $0 == .failure } + guard completed else { + return + } + + let success = mediaStatuses.allSatisfy { $0 == .success } + 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 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", + 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." + ) +}