commit cc5a7dded0ddd49cc6752fbb7561ed8816a9a2aa
parent f87bc1bc95cc3b6d1d8309a6c5de5eb6b2d13cd0
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 17:14:18 -0700
apple-kit: add live background execution adapters
- add AppleKit-owned task scheduler adapter registration
- add background transfer adapter and URLSession shell
- reconcile persisted transfers with active task identifiers
- prove live wrappers through deterministic adapter tests
Diffstat:
5 files changed, 775 insertions(+), 1 deletion(-)
diff --git a/Package.swift b/Package.swift
@@ -26,7 +26,8 @@ let package = Package(
.linkedFramework("UserNotifications"),
.linkedFramework("AVFoundation"),
.linkedFramework("Photos"),
- .linkedFramework("CoreLocation")
+ .linkedFramework("CoreLocation"),
+ .linkedFramework("BackgroundTasks", .when(platforms: [.iOS]))
]
),
.target(
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift
@@ -0,0 +1,180 @@
+import Foundation
+
+#if canImport(BackgroundTasks) && os(iOS)
+import BackgroundTasks
+#endif
+
+public struct RadrootsAppleBackgroundTaskRegistration: Sendable {
+ public let identifier: RadrootsBackgroundTaskIdentifier
+ public let kind: RadrootsBackgroundTaskKind
+ public let handler: @Sendable () async -> Bool
+
+ public init(
+ identifier: RadrootsBackgroundTaskIdentifier,
+ kind: RadrootsBackgroundTaskKind,
+ handler: @escaping @Sendable () async -> Bool
+ ) {
+ self.identifier = identifier
+ self.kind = kind
+ self.handler = handler
+ }
+}
+
+public struct RadrootsAppleBackgroundTaskSchedulerAdapters: Sendable {
+ public let now: @Sendable () -> Date
+ public let register: @Sendable (RadrootsAppleBackgroundTaskRegistration) async throws -> Bool
+ public let submit: @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void
+ public let cancel: @Sendable (RadrootsBackgroundTaskIdentifier) async -> Void
+ public let cancelAll: @Sendable () async -> Void
+ public let pendingTasks: @Sendable () async throws -> [RadrootsBackgroundTaskSnapshot]
+
+ public init(
+ now: @escaping @Sendable () -> Date = Date.init,
+ register: @escaping @Sendable (RadrootsAppleBackgroundTaskRegistration) async throws -> Bool,
+ submit: @escaping @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void,
+ cancel: @escaping @Sendable (RadrootsBackgroundTaskIdentifier) async -> Void,
+ cancelAll: @escaping @Sendable () async -> Void,
+ pendingTasks: @escaping @Sendable () async throws -> [RadrootsBackgroundTaskSnapshot]
+ ) {
+ self.now = now
+ self.register = register
+ self.submit = submit
+ self.cancel = cancel
+ self.cancelAll = cancelAll
+ self.pendingTasks = pendingTasks
+ }
+
+ public static var live: Self {
+ #if canImport(BackgroundTasks) && os(iOS)
+ Self(
+ register: { registration in
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: registration.identifier.rawValue,
+ using: nil
+ ) { task in
+ Task {
+ let success = await registration.handler()
+ task.setTaskCompleted(success: success)
+ }
+ }
+ },
+ submit: { request in
+ try BGTaskScheduler.shared.submit(Self.platformRequest(for: request))
+ },
+ cancel: { identifier in
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier.rawValue)
+ },
+ cancelAll: {
+ BGTaskScheduler.shared.cancelAllTaskRequests()
+ },
+ pendingTasks: {
+ try await Self.pendingPlatformTaskSnapshots()
+ }
+ )
+ #else
+ Self.unavailable
+ #endif
+ }
+
+ public static let unavailable = Self(
+ register: { _ in
+ throw RadrootsBackgroundTaskError.unavailable("background task scheduling is unavailable on this platform")
+ },
+ submit: { _ in
+ throw RadrootsBackgroundTaskError.unavailable("background task scheduling is unavailable on this platform")
+ },
+ cancel: { _ in },
+ cancelAll: {},
+ pendingTasks: {
+ throw RadrootsBackgroundTaskError.unavailable("background task scheduling is unavailable on this platform")
+ }
+ )
+}
+
+public final class RadrootsAppleBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler, Sendable {
+ private let adapters: RadrootsAppleBackgroundTaskSchedulerAdapters
+
+ public init(adapters: RadrootsAppleBackgroundTaskSchedulerAdapters = .live) {
+ self.adapters = adapters
+ }
+
+ @discardableResult
+ public func register(_ registration: RadrootsAppleBackgroundTaskRegistration) async throws -> Bool {
+ try await adapters.register(registration)
+ }
+
+ public func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot {
+ try await adapters.submit(request)
+ return try RadrootsBackgroundTaskSnapshot(
+ request: request,
+ submittedAt: adapters.now()
+ )
+ }
+
+ public func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws {
+ await adapters.cancel(identifier)
+ }
+
+ public func cancelAll() async throws {
+ await adapters.cancelAll()
+ }
+
+ public func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] {
+ try await adapters.pendingTasks()
+ }
+}
+
+#if canImport(BackgroundTasks) && os(iOS)
+private extension RadrootsAppleBackgroundTaskSchedulerAdapters {
+ static func platformRequest(for request: RadrootsBackgroundTaskRequest) -> BGTaskRequest {
+ let platformRequest: BGTaskRequest
+ switch request.kind {
+ case .appRefresh:
+ platformRequest = BGAppRefreshTaskRequest(identifier: request.identifier.rawValue)
+ case .processing:
+ let processingRequest = BGProcessingTaskRequest(identifier: request.identifier.rawValue)
+ processingRequest.requiresNetworkConnectivity = request.requiresNetworkConnectivity
+ processingRequest.requiresExternalPower = request.requiresExternalPower
+ platformRequest = processingRequest
+ }
+ platformRequest.earliestBeginDate = request.earliestBeginDate
+ return platformRequest
+ }
+
+ static func pendingPlatformTaskSnapshots() async throws -> [RadrootsBackgroundTaskSnapshot] {
+ let requests = await withCheckedContinuation { continuation in
+ BGTaskScheduler.shared.getPendingTaskRequests { requests in
+ continuation.resume(returning: requests)
+ }
+ }
+ return try requests.compactMap { request in
+ guard let identifier = try? RadrootsBackgroundTaskIdentifier(request.identifier) else {
+ return nil
+ }
+ let kind: RadrootsBackgroundTaskKind
+ let requiresNetworkConnectivity: Bool
+ let requiresExternalPower: Bool
+ if let processingRequest = request as? BGProcessingTaskRequest {
+ kind = .processing
+ requiresNetworkConnectivity = processingRequest.requiresNetworkConnectivity
+ requiresExternalPower = processingRequest.requiresExternalPower
+ } else {
+ kind = .appRefresh
+ requiresNetworkConnectivity = false
+ requiresExternalPower = false
+ }
+ return try RadrootsBackgroundTaskSnapshot(
+ identifier: identifier,
+ kind: kind,
+ earliestBeginDate: request.earliestBeginDate,
+ submittedAt: Date(),
+ requiresNetworkConnectivity: requiresNetworkConnectivity,
+ requiresExternalPower: requiresExternalPower
+ )
+ }
+ .sorted { left, right in
+ left.identifier < right.identifier
+ }
+ }
+}
+#endif
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift
@@ -0,0 +1,242 @@
+import Foundation
+
+public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
+ public let now: @Sendable () -> Date
+ public let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void
+ public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void
+ public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
+ public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void
+
+ public init(
+ now: @escaping @Sendable () -> Date = Date.init,
+ enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void,
+ cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void,
+ activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>,
+ handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void
+ ) {
+ self.now = now
+ self.enqueue = enqueue
+ self.cancel = cancel
+ self.activeTransferIdentifiers = activeTransferIdentifiers
+ self.handleBackgroundEvents = handleBackgroundEvents
+ }
+
+ public static let unavailable = Self(
+ enqueue: { _ in
+ throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
+ },
+ cancel: { _ in
+ throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
+ },
+ activeTransferIdentifiers: {
+ throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform")
+ },
+ handleBackgroundEvents: { _, completionHandler in
+ completionHandler()
+ }
+ )
+
+ public static func live(
+ sessionIdentifier: String,
+ fileResolver: any RadrootsBackgroundTransferFileResolver
+ ) throws -> Self {
+ #if os(iOS)
+ let normalizedSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier)
+ let session = RadrootsAppleBackgroundURLSession(identifier: normalizedSessionIdentifier)
+ return Self(
+ enqueue: { request in
+ try await session.enqueue(request, fileResolver: fileResolver)
+ },
+ cancel: { identifier in
+ await session.cancel(identifier)
+ },
+ activeTransferIdentifiers: {
+ await session.activeTransferIdentifiers()
+ },
+ handleBackgroundEvents: { identifier, completionHandler in
+ await session.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
+ }
+ )
+ #else
+ return .unavailable
+ #endif
+ }
+}
+
+public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
+ private let store: any RadrootsBackgroundTransferStore
+ private let adapters: RadrootsAppleBackgroundTransferAdapters
+
+ public init(
+ store: any RadrootsBackgroundTransferStore,
+ adapters: RadrootsAppleBackgroundTransferAdapters
+ ) {
+ self.store = store
+ self.adapters = adapters
+ }
+
+ public convenience init(
+ roots: RadrootsAppleFileRoots,
+ sessionIdentifier: String
+ ) throws {
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ try self.init(
+ store: RadrootsAppleBackgroundTransferStore(roots: roots),
+ adapters: .live(sessionIdentifier: sessionIdentifier, fileResolver: resolver)
+ )
+ }
+
+ public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .queued,
+ updatedAt: adapters.now()
+ )
+ )
+ do {
+ try await adapters.enqueue(request)
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .running,
+ updatedAt: adapters.now()
+ )
+ )
+ return RadrootsBackgroundTransferHandle(request: request)
+ } catch {
+ let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .failed,
+ errorMessage: message,
+ updatedAt: adapters.now()
+ )
+ )
+ throw error
+ }
+ }
+
+ public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ try await adapters.cancel(identifier)
+ if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
+ try await store.saveSnapshot(
+ try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .cancelled,
+ progress: existing.progress,
+ updatedAt: adapters.now()
+ )
+ )
+ }
+ }
+
+ public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
+ try await snapshots().first { $0.identifier == identifier }
+ }
+
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ let activeIdentifiers = try await adapters.activeTransferIdentifiers()
+ let storedSnapshots = try await store.loadSnapshots()
+ var reconciled: [RadrootsBackgroundTransferSnapshot] = []
+ for snapshot in storedSnapshots {
+ if activeIdentifiers.contains(snapshot.identifier), snapshot.state == .queued {
+ let runningSnapshot = try RadrootsBackgroundTransferSnapshot(
+ request: snapshot.request,
+ state: .running,
+ progress: snapshot.progress,
+ errorMessage: snapshot.errorMessage,
+ updatedAt: adapters.now()
+ )
+ try await store.saveSnapshot(runningSnapshot)
+ reconciled.append(runningSnapshot)
+ } else {
+ reconciled.append(snapshot)
+ }
+ }
+ return reconciled.sorted { left, right in
+ left.identifier < right.identifier
+ }
+ }
+
+ public func handleEventsForBackgroundURLSession(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) async {
+ await adapters.handleBackgroundEvents(identifier, completionHandler)
+ }
+}
+
+#if os(iOS)
+private actor RadrootsAppleBackgroundURLSession {
+ private let identifier: String
+ private var completionHandlersByIdentifier: [String: @Sendable () -> Void]
+
+ init(identifier: String) {
+ self.identifier = identifier
+ self.completionHandlersByIdentifier = [:]
+ }
+
+ func enqueue(
+ _ request: RadrootsBackgroundTransferRequest,
+ fileResolver: any RadrootsBackgroundTransferFileResolver
+ ) async throws {
+ let session = backgroundSession()
+ var urlRequest = URLRequest(url: request.remoteURL)
+ urlRequest.httpMethod = request.method.rawValue
+ for (key, value) in request.headers {
+ urlRequest.setValue(value, forHTTPHeaderField: key)
+ }
+ let task: URLSessionTask
+ switch request.operation {
+ case .download:
+ task = session.downloadTask(with: urlRequest)
+ case .upload(let source):
+ task = try session.uploadTask(with: urlRequest, fromFile: fileResolver.resolve(source))
+ }
+ task.taskDescription = request.identifier.rawValue
+ task.resume()
+ }
+
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
+ let tasks = await allTasks()
+ for task in tasks where task.taskDescription == identifier.rawValue {
+ task.cancel()
+ }
+ }
+
+ func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
+ let tasks = await allTasks()
+ let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
+ guard let taskDescription = task.taskDescription else {
+ return nil
+ }
+ return try? RadrootsBackgroundTransferIdentifier(taskDescription)
+ }
+ return Set(identifiers)
+ }
+
+ func handleBackgroundEvents(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) {
+ completionHandlersByIdentifier[identifier] = completionHandler
+ }
+
+ private func allTasks() async -> [URLSessionTask] {
+ await withCheckedContinuation { continuation in
+ backgroundSession().getAllTasks { tasks in
+ continuation.resume(returning: tasks)
+ }
+ }
+ }
+
+ private func backgroundSession() -> URLSession {
+ let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
+ configuration.sessionSendsLaunchEvents = true
+ configuration.isDiscretionary = false
+ return URLSession(configuration: configuration)
+ }
+}
+#endif
diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift
@@ -0,0 +1,157 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func appleBackgroundTaskSchedulerRegistersAndSubmitsThroughAdapters() async throws {
+ let probe = RadrootsAppleBackgroundTaskSchedulerProbe(now: Date(timeIntervalSince1970: 100))
+ let scheduler = RadrootsAppleBackgroundTaskScheduler(adapters: probe.adapters())
+ let identifier = try RadrootsBackgroundTaskIdentifier("org.radroots.field-ios.background.refresh")
+ let registration = RadrootsAppleBackgroundTaskRegistration(
+ identifier: identifier,
+ kind: .appRefresh,
+ handler: { true }
+ )
+ let request = try RadrootsBackgroundTaskRequest(
+ identifier: identifier,
+ kind: .appRefresh,
+ earliestBeginDate: Date(timeIntervalSince1970: 120)
+ )
+
+ #expect(try await scheduler.register(registration))
+ let snapshot = try await scheduler.submit(request)
+
+ #expect(snapshot.identifier == identifier)
+ #expect(snapshot.submittedAt == Date(timeIntervalSince1970: 100))
+ #expect(await probe.registeredIdentifiers == [identifier])
+ #expect(await probe.submittedRequests == [request])
+}
+
+@Test func appleBackgroundTaskSchedulerCancelsAndListsPendingTasksThroughAdapters() async throws {
+ let identifier = try RadrootsBackgroundTaskIdentifier("org.radroots.field-ios.background.processing")
+ let request = try RadrootsBackgroundTaskRequest(
+ identifier: identifier,
+ kind: .processing,
+ requiresNetworkConnectivity: true
+ )
+ let pendingSnapshot = try RadrootsBackgroundTaskSnapshot(
+ request: request,
+ submittedAt: Date(timeIntervalSince1970: 5)
+ )
+ let probe = RadrootsAppleBackgroundTaskSchedulerProbe(pendingSnapshots: [pendingSnapshot])
+ let scheduler = RadrootsAppleBackgroundTaskScheduler(adapters: probe.adapters())
+
+ try await scheduler.cancel(identifier)
+ try await scheduler.cancelAll()
+
+ #expect(await probe.cancelledIdentifiers == [identifier])
+ #expect(await probe.cancelAllCount == 1)
+ #expect(try await scheduler.pendingTasks() == [pendingSnapshot])
+}
+
+@Test func appleBackgroundTaskSchedulerMapsAdapterSubmitFailures() async throws {
+ let probe = RadrootsAppleBackgroundTaskSchedulerProbe(
+ submitOutcome: .failure(.schedulerFailure("submit rejected"))
+ )
+ let scheduler = RadrootsAppleBackgroundTaskScheduler(adapters: probe.adapters())
+ let request = try RadrootsBackgroundTaskRequest(
+ identifier: "org.radroots.field-ios.background.refresh",
+ kind: .appRefresh
+ )
+
+ await #expect(throws: RadrootsBackgroundTaskError.schedulerFailure("submit rejected")) {
+ _ = try await scheduler.submit(request)
+ }
+}
+
+private actor RadrootsAppleBackgroundTaskSchedulerProbe {
+ private let nowValue: Date
+ private let registerResult: Bool
+ private let submitOutcome: Result<Void, RadrootsBackgroundTaskError>
+ private var pendingSnapshotsValue: [RadrootsBackgroundTaskSnapshot]
+ private var registeredIdentifiersValue: [RadrootsBackgroundTaskIdentifier]
+ private var submittedRequestsValue: [RadrootsBackgroundTaskRequest]
+ private var cancelledIdentifiersValue: [RadrootsBackgroundTaskIdentifier]
+ private var cancelAllCountValue: Int
+
+ init(
+ now: Date = Date(timeIntervalSince1970: 0),
+ registerResult: Bool = true,
+ submitOutcome: Result<Void, RadrootsBackgroundTaskError> = .success(()),
+ pendingSnapshots: [RadrootsBackgroundTaskSnapshot] = []
+ ) {
+ self.nowValue = now
+ self.registerResult = registerResult
+ self.submitOutcome = submitOutcome
+ self.pendingSnapshotsValue = pendingSnapshots
+ self.registeredIdentifiersValue = []
+ self.submittedRequestsValue = []
+ self.cancelledIdentifiersValue = []
+ self.cancelAllCountValue = 0
+ }
+
+ nonisolated func adapters() -> RadrootsAppleBackgroundTaskSchedulerAdapters {
+ RadrootsAppleBackgroundTaskSchedulerAdapters(
+ now: {
+ self.nowValue
+ },
+ register: { registration in
+ await self.register(registration)
+ },
+ submit: { request in
+ try await self.submit(request)
+ },
+ cancel: { identifier in
+ await self.cancel(identifier)
+ },
+ cancelAll: {
+ await self.cancelAll()
+ },
+ pendingTasks: {
+ await self.pendingSnapshots()
+ }
+ )
+ }
+
+ private func register(_ registration: RadrootsAppleBackgroundTaskRegistration) -> Bool {
+ registeredIdentifiersValue.append(registration.identifier)
+ return registerResult
+ }
+
+ private func submit(_ request: RadrootsBackgroundTaskRequest) throws {
+ submittedRequestsValue.append(request)
+ switch submitOutcome {
+ case .success:
+ return
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ private func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) {
+ cancelledIdentifiersValue.append(identifier)
+ }
+
+ private func cancelAll() {
+ cancelAllCountValue += 1
+ }
+
+ private func pendingSnapshots() -> [RadrootsBackgroundTaskSnapshot] {
+ pendingSnapshotsValue
+ }
+
+ var registeredIdentifiers: [RadrootsBackgroundTaskIdentifier] {
+ registeredIdentifiersValue
+ }
+
+ var submittedRequests: [RadrootsBackgroundTaskRequest] {
+ submittedRequestsValue
+ }
+
+ var cancelledIdentifiers: [RadrootsBackgroundTaskIdentifier] {
+ cancelledIdentifiersValue
+ }
+
+ var cancelAllCount: Int {
+ cancelAllCountValue
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift
@@ -0,0 +1,194 @@
+import Foundation
+import Testing
+import RadrootsKitTesting
+@testable import RadrootsKit
+
+@Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.enqueue")
+
+ let handle = try await transfer.enqueue(request)
+
+ #expect(handle.identifier == request.identifier)
+ #expect(await probe.enqueuedRequests == [request])
+ let snapshot = try await transfer.snapshot(for: request.identifier)
+ #expect(snapshot?.state == .running)
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100))
+}
+
+@Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(
+ now: Date(timeIntervalSince1970: 200),
+ enqueueOutcome: .failure(.transferFailure("adapter rejected transfer"))
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.failed")
+
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure("adapter rejected transfer")) {
+ _ = try await transfer.enqueue(request)
+ }
+
+ let snapshot = try await transfer.snapshot(for: request.identifier)
+ #expect(snapshot?.state == .failed)
+ #expect(snapshot?.errorMessage == "adapter rejected transfer")
+ #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200))
+}
+
+@Test func appleBackgroundTransferCancelsThroughAdapterAndUpdatesStore() async throws {
+ let store = RadrootsInMemoryBackgroundTransferStore()
+ let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300))
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+ let request = try appleTransferRequest(identifier: "field.transfer.cancel")
+
+ _ = try await transfer.enqueue(request)
+ try await transfer.cancel(request.identifier)
+
+ #expect(await probe.cancelledIdentifiers == [request.identifier])
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled)
+ #expect(try await transfer.snapshot(for: request.identifier)?.updatedAt == Date(timeIntervalSince1970: 300))
+}
+
+@Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws {
+ let request = try appleTransferRequest(identifier: "field.transfer.recovered")
+ let queued = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .queued,
+ updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued])
+ let probe = RadrootsAppleBackgroundTransferProbe(
+ now: Date(timeIntervalSince1970: 400),
+ activeIdentifiers: [request.identifier]
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
+
+ let snapshots = try await transfer.snapshots()
+
+ #expect(snapshots.count == 1)
+ #expect(snapshots.first?.state == .running)
+ #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400))
+ #expect(try await store.loadSnapshots().first?.state == .running)
+}
+
+@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async throws {
+ let probe = RadrootsAppleBackgroundTransferProbe()
+ let transfer = RadrootsAppleBackgroundTransfer(
+ store: RadrootsInMemoryBackgroundTransferStore(),
+ adapters: probe.adapters()
+ )
+ let completion = RadrootsCompletionProbe()
+
+ await transfer.handleEventsForBackgroundURLSession(identifier: "org.radroots.field-ios.background.transfer") {
+ completion.markCompleted()
+ }
+
+ #expect(await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"])
+ #expect(completion.completed)
+}
+
+private actor RadrootsAppleBackgroundTransferProbe {
+ private let nowValue: Date
+ private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
+ private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier>
+ private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
+ private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
+ private var handledBackgroundEventIdentifiersValue: [String]
+
+ init(
+ now: Date = Date(timeIntervalSince1970: 0),
+ enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
+ activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = []
+ ) {
+ self.nowValue = now
+ self.enqueueOutcome = enqueueOutcome
+ self.activeIdentifiersValue = activeIdentifiers
+ self.enqueuedRequestsValue = []
+ self.cancelledIdentifiersValue = []
+ self.handledBackgroundEventIdentifiersValue = []
+ }
+
+ nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters {
+ RadrootsAppleBackgroundTransferAdapters(
+ now: {
+ self.nowValue
+ },
+ enqueue: { request in
+ try await self.enqueue(request)
+ },
+ cancel: { identifier in
+ await self.cancel(identifier)
+ },
+ activeTransferIdentifiers: {
+ await self.activeIdentifiers()
+ },
+ handleBackgroundEvents: { identifier, completionHandler in
+ await self.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
+ }
+ )
+ }
+
+ private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws {
+ enqueuedRequestsValue.append(request)
+ switch enqueueOutcome {
+ case .success:
+ activeIdentifiersValue.insert(request.identifier)
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) {
+ cancelledIdentifiersValue.append(identifier)
+ activeIdentifiersValue.remove(identifier)
+ }
+
+ private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> {
+ activeIdentifiersValue
+ }
+
+ private func handleBackgroundEvents(
+ identifier: String,
+ completionHandler: @escaping @Sendable () -> Void
+ ) {
+ handledBackgroundEventIdentifiersValue.append(identifier)
+ completionHandler()
+ }
+
+ var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
+ enqueuedRequestsValue
+ }
+
+ var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
+ cancelledIdentifiersValue
+ }
+
+ var handledBackgroundEventIdentifiers: [String] {
+ handledBackgroundEventIdentifiersValue
+ }
+}
+
+private final class RadrootsCompletionProbe: @unchecked Sendable {
+ private var completedValue = false
+
+ func markCompleted() {
+ completedValue = true
+ }
+
+ var completed: Bool {
+ completedValue
+ }
+}
+
+private func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest {
+ try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier(identifier),
+ remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
+ method: .get,
+ operation: .download(
+ destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))
+ )
+ )
+}