Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation
import Testing

@testable import WordPress

struct ItemProviderMediaExporterErrorTests {

/// The exact error shape observed on device (under iOS Lockdown Mode) when the
/// Photos provider is killed while materializing a large image:
/// `NSItemProviderErrorDomain -1000` wrapping `NSCocoaErrorDomain 4099`.
@Test func detectsProviderProcessDeathFromNestedXPCError() {
let xpcError = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.xpcConnectionInvalid.rawValue)
let itemProviderError = NSError(
domain: "NSItemProviderErrorDomain",
code: -1000,
userInfo: [NSUnderlyingErrorKey: xpcError]
)

let match = ItemProviderMediaExporter.providerConnectionError(in: itemProviderError)

#expect(match?.domain == NSCocoaErrorDomain)
#expect(match?.code == CocoaError.Code.xpcConnectionInvalid.rawValue)
}

@Test func detectsXPCConnectionInterruptedAtTopLevel() {
let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.xpcConnectionInterrupted.rawValue)
#expect(ItemProviderMediaExporter.providerConnectionError(in: error) != nil)
}

@Test func findsXPCErrorNestedSeveralLevelsDeep() {
let xpcError = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.xpcConnectionReplyInvalid.rawValue)
let middle = NSError(domain: "Middle", code: 1, userInfo: [NSUnderlyingErrorKey: xpcError])
let outer = NSError(domain: "Outer", code: 2, userInfo: [NSUnderlyingErrorKey: middle])
#expect(ItemProviderMediaExporter.providerConnectionError(in: outer) != nil)
}

/// A provider-death XPC error can be buried several layers down a wrapped chain.
/// The classifier must still find it: the chain-walk previously enqueued every
/// wrapped error twice (`underlyingErrors` *and* `NSUnderlyingErrorKey`), exhausting
/// its traversal budget long before reaching errors this deep.
@Test func findsXPCErrorFiveLevelsDeep() {
let xpcError = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.xpcConnectionInvalid.rawValue)
// Bury the XPC error five `NSUnderlyingErrorKey` levels down.
var nested: Error = xpcError
for level in 1...5 {
nested = NSError(domain: "Wrapper\(level)", code: level, userInfo: [NSUnderlyingErrorKey: nested])
}
let match = ItemProviderMediaExporter.providerConnectionError(in: nested)
#expect(match?.code == CocoaError.Code.xpcConnectionInvalid.rawValue)
}

/// A generic Cocoa error that is *not* an XPC connection failure must not match —
/// otherwise every load failure would be misreported as a provider process death.
@Test func ignoresUnrelatedCocoaError() {
let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileNoSuchFile.rawValue)
#expect(ItemProviderMediaExporter.providerConnectionError(in: error) == nil)
}

/// The same numeric code in a different domain is not an XPC error.
@Test func ignoresXPCCodeInADifferentDomain() {
let error = NSError(domain: "SomeOtherDomain", code: CocoaError.Code.xpcConnectionInvalid.rawValue)
#expect(ItemProviderMediaExporter.providerConnectionError(in: error) == nil)
}

// MARK: - Cancellation

/// A user-cancelled load must not be surfaced as an error (it would show a
/// spurious "failed" message for an upload the user deliberately cancelled).
@Test func detectsUserCancellation() {
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)
#expect(ItemProviderMediaExporter.isCancellation(error))
}

@Test func detectsURLCancellation() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)
#expect(ItemProviderMediaExporter.isCancellation(error))
}

@Test func detectsSwiftConcurrencyCancellation() {
#expect(ItemProviderMediaExporter.isCancellation(CancellationError()))
}

@Test func detectsCancellationNestedInChain() {
let cancel = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)
let outer = NSError(domain: "Outer", code: 1, userInfo: [NSUnderlyingErrorKey: cancel])
#expect(ItemProviderMediaExporter.isCancellation(outer))
}

/// A provider-death (XPC) error is a genuine failure, not a cancellation.
@Test func doesNotTreatXPCFailureAsCancellation() {
let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.xpcConnectionInvalid.rawValue)
#expect(!ItemProviderMediaExporter.isCancellation(error))
}

@Test func doesNotTreatUnrelatedErrorAsCancellation() {
let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileNoSuchFile.rawValue)
#expect(!ItemProviderMediaExporter.isCancellation(error))
}
}
3 changes: 3 additions & 0 deletions WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import WordPressShared
case mediaStorageDetailsViewed
case mediaStorageDetailsActionTapped
case mediaStorageDetailsPurchaseCompleted
case mediaImportItemUnavailable

// Settings and Prepublishing Nudges
case editorPostPublishTap
Expand Down Expand Up @@ -732,6 +733,8 @@ import WordPressShared
return "media_storage_details_action_tapped"
case .mediaStorageDetailsPurchaseCompleted:
return "media_storage_details_purchase_completed"
case .mediaImportItemUnavailable:
return "media_import_item_unavailable"
// Editor
case .editorPostPublishTap:
return "editor_post_publish_tapped"
Expand Down
14 changes: 14 additions & 0 deletions WordPress/Classes/Utility/LockdownHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// Reports whether iOS Lockdown Mode is enabled.
///
/// Lockdown Mode can only be toggled with a device restart, so its state is constant
/// for the lifetime of the process. The flag is therefore read once — lazily, on first
/// access — from the system-maintained `LDMGlobalEnabled` user default; `static let`
/// makes that read thread-safe and the result immutable.
enum LockdownHelper {
/// The system-maintained global user default, set while Lockdown Mode is enabled.
private static let lockdownModeDefaultsKey = "LDMGlobalEnabled"

static let isLockdownModeEnabled = UserDefaults.standard.bool(forKey: lockdownModeDefaultsKey)
}
120 changes: 117 additions & 3 deletions WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import PhotosUI
import WordPressData
import WordPressShared

/// Manages export of media assets: images and video.
final class ItemProviderMediaExporter: MediaExporter {
Expand Down Expand Up @@ -84,7 +85,7 @@ final class ItemProviderMediaExporter: MediaExporter {

let loadProgress = provider.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
guard let url else {
DDLogDebug("Loaded file representation for provider: \(ObjectIdentifier(self.provider)), error: \(String(describing: error)))")
self.handleLoadFailure(error, onError: onError)
return
}
let diff = CFAbsoluteTimeGetCurrent() - start
Expand Down Expand Up @@ -135,18 +136,131 @@ final class ItemProviderMediaExporter: MediaExporter {
provider.hasItemConformingToTypeIdentifier(type.identifier)
}

/// Surfaces a failure to load the picked file from the `NSItemProvider`.
///
/// When the provider's connection died (an XPC failure), the app shows a friendly
/// message — specific to Lockdown Mode when it's enabled — and tracks the event so
/// this case can be told apart from ordinary load failures. Any other error is
/// surfaced as-is.
///
/// A cancelled load (the user cancelled the upload, or the system cancelled the
/// request) is *not* surfaced — cancellation is reported separately by the upload
/// coordinator, so calling `onError` here would show a spurious failure.
///
/// Observed only with iOS Lockdown Mode enabled: materializing a large photo
/// (e.g. 36 MP) fails and the `PhotosFileProvider` process is killed, giving
/// `NSItemProviderError -1000` over `NSCocoaErrorDomain 4099`.
private func handleLoadFailure(_ error: Error?, onError: (MediaExportError) -> Void) {
let providerID = ObjectIdentifier(provider)
guard let error else {
DDLogError("Failed to load file representation for provider: \(providerID), error: nil")
onError(ExportError.unknown)
return
}
// A cancelled load isn't a failure to report: the upload coordinator cancels
// this request's `Progress` (which fires this completion with a cancellation
// error) and surfaces the cancellation itself. Bail out without calling
// `onError` so we don't show a spurious "failed" message.
if ItemProviderMediaExporter.isCancellation(error) {
DDLogInfo("Cancelled loading file representation for provider: \(providerID)")
return
}
DDLogError("Failed to load file representation for provider: \(providerID), error: \(error)")
if let connectionError = ItemProviderMediaExporter.providerConnectionError(in: error) {
let isLockdownModeEnabled = LockdownHelper.isLockdownModeEnabled
WPAnalytics.track(.mediaImportItemUnavailable, properties: providerErrorProperties(for: error, connectionError: connectionError, isLockdownModeEnabled: isLockdownModeEnabled))
onError(isLockdownModeEnabled ? ExportError.lockdownModeRestricted : ExportError.cannotLoadItem)
} else {
onError(ExportError.underlyingError(error))
}
}

private func providerErrorProperties(for error: Error, connectionError: NSError, isLockdownModeEnabled: Bool) -> [AnyHashable: Any] {
let error = error as NSError
return [
"error_domain": error.domain,
"error_code": error.code,
"underlying_error_domain": connectionError.domain,
"underlying_error_code": connectionError.code,
"type_identifiers": provider.registeredTypeIdentifiers.joined(separator: ", "),
"lockdown_mode": isLockdownModeEnabled
]
}

/// The XPC connection error codes (in `NSCocoaErrorDomain`) that signal the item
/// provider's process died while producing the file.
private static let xpcConnectionErrorCodes: Set<Int> = [
CocoaError.Code.xpcConnectionInterrupted.rawValue,
CocoaError.Code.xpcConnectionInvalid.rawValue,
CocoaError.Code.xpcConnectionReplyInvalid.rawValue
]

/// Returns the first XPC connection error found in `error` or any of its
/// underlying errors, or `nil` if there is none.
static func providerConnectionError(in error: Error) -> NSError? {
errorChain(from: error)
.map { $0 as NSError }
.first { $0.domain == NSCocoaErrorDomain && xpcConnectionErrorCodes.contains($0.code) }
}

/// Returns `true` when `error`, or any error it wraps, represents a cancellation
/// rather than a genuine failure — a `CancellationError`, `NSUserCancelledError`,
/// or `NSURLErrorCancelled`.
static func isCancellation(_ error: Error) -> Bool {
errorChain(from: error).contains { element in
if element is CancellationError {
return true
}
let nsError = element as NSError
switch (nsError.domain, nsError.code) {
case (NSCocoaErrorDomain, NSUserCancelledError),
(NSURLErrorDomain, NSURLErrorCancelled):
return true
default:
return false
}
}
}

/// Flattens `error` and its underlying errors into a single list, bounded to guard
/// against pathological cycles.
///
/// `NSError.underlyingErrors` already surfaces the value stored under the legacy
/// `NSUnderlyingErrorKey` (along with `NSMultipleUnderlyingErrorsKey`), so it is the
/// only source we enqueue. Reading `NSUnderlyingErrorKey` separately as well would
/// visit every wrapped error twice and halve the depth reachable before the bound.
private static func errorChain(from error: Error) -> [Error] {
var result: [Error] = []
var queue: [Error] = [error]
while let next = queue.first, result.count < 16 {
queue.removeFirst()
result.append(next)
queue.append(contentsOf: (next as NSError).underlyingErrors)
}
return result
}

enum ExportError: MediaExportError {
case unsupportedContentType
case underlyingError(Error?)
case cannotLoadItem
case lockdownModeRestricted
case underlyingError(Error)
case unknown

public var errorDescription: String? { description }

var description: String {
switch self {
case .unsupportedContentType:
return NSLocalizedString("mediaExporter.error.unsupportedContentType", value: "Unsupported content type", comment: "An error message the app shows if media import fails")
case .cannotLoadItem:
return NSLocalizedString("mediaExporter.error.cannotLoadItem", value: "This item could not be added to the Media library. It may be too large to import.", comment: "Error shown when a selected photo or video can't be loaded from the device for upload.")
case .lockdownModeRestricted:
return NSLocalizedString("mediaExporter.error.lockdownMode", value: "This item can’t be added to the Media library while Lockdown Mode is on.", comment: "Error shown when a selected photo or video can't be imported because iOS Lockdown Mode is enabled.")
case .underlyingError(let error):
return error?.localizedDescription ?? NSLocalizedString("mediaExporter.error.unknown", value: "The item could not be added to the Media library", comment: "An error message the app shows if media import fails")
return error.localizedDescription
case .unknown:
return NSLocalizedString("mediaExporter.error.unknown", value: "The item could not be added to the Media library", comment: "An error message the app shows if media import fails")
}
}
}
Expand Down