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."
+ )
+}