apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

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:
MPackage.swift | 3++-
ASources/RadrootsKit/RadrootsAppleBackgroundTasks.swift | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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")) + ) + ) +}