diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataDownloadTask.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataDownloadTask.swift new file mode 100644 index 00000000..8d1fddbe --- /dev/null +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataDownloadTask.swift @@ -0,0 +1,125 @@ +import BackgroundTasks +import Foundation +import os + +/// Keeps a nav-data import running after the pilot leaves the app. +/// +/// An import takes minutes, and until now the consent screen had to ask that the app be left open, +/// because a plain `Task` gets seconds of runtime once the app is backgrounded and the transfer +/// would restart from nothing. A continued-processing task buys that time, and gives the system a +/// progress and cancel affordance the app does not have to draw. +/// +/// This is only safe now that an import writes a generation nothing is reading. The system expires +/// these tasks on changing conditions, and cancels them outright when the app is swiped out of the +/// switcher, **without telling the app** — against an import that emptied the live dataset first, +/// that meant an empty airport database. +/// +/// Failing to get a task is not an error. The import runs either way; it just runs unprotected, as +/// it always did. +@MainActor +final class NavDataDownloadTask { + /// The identifier this task is registered and submitted under. + /// + /// One fixed string, advertised verbatim in `BGTaskSchedulerPermittedIdentifiers`. A handler has + /// to be registered while launching, so the identifier it answers for has to be known then — + /// which rules out naming each submission after the import it belongs to. + static let identifier = "codes.tim.SF50-TOLD.navdata-download" + + /// The shared task broker. + static let shared = NavDataDownloadTask() + + private let logger = Logger( + subsystem: "codes.tim.SF50-TOLD", + category: "NavDataDownloadTask" + ) + + private var isRegistered = false + + /// Resumed when the system hands over a task, carrying nothing: `BGContinuedProcessingTask` is + /// not `Sendable`, and it never has to leave this actor to be handed back. + private var pendingStart: CheckedContinuation? + private var startedTask: BGContinuedProcessingTask? + + /// Whether a background task should be asked for at all. + /// + /// Inert under UI testing and screenshot generation, so XCTest's wait-for-idle never stalls on + /// system-owned work and no request is submitted during a test run. + private var isEnabled: Bool { + let arguments = ProcessInfo.processInfo.arguments + return !arguments.contains("UI-TESTING") && !arguments.contains("GENERATE-SCREENSHOTS") + } + + private init() {} + + /// Registers the launch handler. + /// + /// Called while the app is launching, as every other task identifier here is. Registering later + /// is refused — the permitted identifiers are read once, and a handler offered afterwards is not + /// matched against them. + func registerHandler() { + guard isEnabled, !isRegistered else { return } + + isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.identifier, + using: .main + ) { [weak self] task in + MainActor.assumeIsolated { + guard let continuedProcessing = task as? BGContinuedProcessingTask else { + task.setTaskCompleted(success: false) + return + } + self?.start(continuedProcessing) + } + } + + if !isRegistered { + logger.error( + "Couldn’t register \(Self.identifier, privacy: .public); is it permitted in Info.plist?" + ) + } + } + + /// Asks the system to let the import keep running when the app is backgrounded. + /// + /// - Parameters: + /// - title: What the system should call this work. + /// - subtitle: The phase to show beneath it. + /// - Returns: The task to report progress to, or `nil` if the system would not start one — in + /// which case the caller carries on unprotected. + func begin(title: String, subtitle: String) async -> BGContinuedProcessingTask? { + guard isEnabled, isRegistered else { return nil } + + let request = BGContinuedProcessingTaskRequest( + identifier: Self.identifier, + title: title, + subtitle: subtitle + ) + // Start now or not at all: a queued request waits on system load, and the import would rather + // run unprotected than wait to begin. + request.strategy = .fail + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + logger.notice("Running the import unprotected: \(error.localizedDescription)") + return nil + } + + await withCheckedContinuation { continuation in + pendingStart = continuation + } + defer { startedTask = nil } + return startedTask + } + + private func start(_ task: BGContinuedProcessingTask) { + guard let continuation = pendingStart else { + // Nothing is waiting for this — most likely a request that outlived its import. + task.setTaskCompleted(success: false) + return + } + pendingStart = nil + startedTask = task + continuation.resume() + } +} diff --git a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift index 34fe0880..c2900e9d 100644 --- a/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift +++ b/SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift @@ -1,3 +1,4 @@ +import BackgroundTasks import Defaults import Observation import SF50_Shared @@ -27,6 +28,9 @@ import SwiftData @Observable @MainActor final class NavDataLoaderViewModel: WithIdentifiableError { + /// The scale the loader's 0…1 progress is reported to the system on. + private static let progressUnits: Int64 = 100 + private(set) var state: NavDataLoader.State = .idle var error: (any Swift.Error)? @@ -158,9 +162,30 @@ final class NavDataLoaderViewModel: WithIdentifiableError { private func runLoad() async { let generation = installer.reserveGeneration() guard let loader = await makeLoader(generation: generation) else { return } - let progressTask = await observeProgress(of: loader) + + // Ask the system to let this keep running if the pilot leaves the app. Safe now that an import + // writes a generation nothing reads: a task the system cancels costs a file, not a database. + let backgroundTask = await NavDataDownloadTask.shared.begin( + title: String(localized: "Updating Navigation Data"), + subtitle: String(localized: "Downloading") + ) + backgroundTask?.expirationHandler = { [weak self] in + MainActor.assumeIsolated { self?.cancelLoad() } + } + + let progressTask = await observeProgress(of: loader, reportingTo: backgroundTask) addTask(progressTask) await performLoad(with: loader, generation: generation, progressTask: progressTask) + backgroundTask?.setTaskCompleted(success: error == nil) + } + + /// Abandons an import the system has asked to stop. + /// + /// The generation being written is left where it is; nothing points at it, and the next launch + /// reclaims it. The dataset in use was never touched. + private func cancelLoad() { + for task in cancellables { task.cancel() } + state = .idle } private func makeLoader(generation: Int) async -> NavDataLoader? { @@ -184,8 +209,12 @@ final class NavDataLoaderViewModel: WithIdentifiableError { /// The loader yields into an `AsyncStream`, so following its progress never /// enqueues a job onto the loader's executor — an enqueue would block the main /// thread for as long as the import occupies that executor. - private func observeProgress(of loader: NavDataLoader) async -> Task { + private func observeProgress( + of loader: NavDataLoader, + reportingTo backgroundTask: BGContinuedProcessingTask? + ) async -> Task { let updates = await loader.stateUpdates() + backgroundTask?.progress.totalUnitCount = Self.progressUnits return Task { [weak self] in for await loaderState in updates where !Task.isCancelled { guard let self else { return } @@ -193,10 +222,32 @@ final class NavDataLoaderViewModel: WithIdentifiableError { // The actor hasn't begun loading; don't regress the UI to consent if case .idle = loaderState { continue } state = loaderState + report(loaderState, to: backgroundTask) } } } + /// Mirrors the loader's phase and progress onto the system's own display of the work. + /// + /// A continued-processing task must report progress: one the system reads as stalled is expired + /// to reclaim its resources. + private func report(_ state: NavDataLoader.State, to backgroundTask: BGContinuedProcessingTask?) { + guard let backgroundTask else { return } + + let (subtitle, fraction): (String, Float?) = + switch state { + case .idle: (String(localized: "Starting"), 0) + case .downloading(let progress): (String(localized: "Downloading"), progress) + case .extracting: (String(localized: "Decompressing"), nil) + case .loading(let progress): (String(localized: "Processing"), progress) + case .finished: (String(localized: "Finished"), 1) + } + + backgroundTask.updateTitle(String(localized: "Updating Navigation Data"), subtitle: subtitle) + guard let fraction else { return } + backgroundTask.progress.completedUnitCount = Int64(fraction * Float(Self.progressUnits)) + } + private func performLoad( with loader: NavDataLoader, generation: Int, diff --git a/SF50 TOLD/Localizable.xcstrings b/SF50 TOLD/Localizable.xcstrings index 2364eedb..7ca77b62 100644 --- a/SF50 TOLD/Localizable.xcstrings +++ b/SF50 TOLD/Localizable.xcstrings @@ -180,6 +180,24 @@ } } }, + "Decompressing" : { + "comment" : "Phase shown beneath the nav-data download title while expanding the payload." + }, + "Downloading" : { + "comment" : "Phase shown beneath the nav-data download title while transferring." + }, + "Finished" : { + "comment" : "Phase shown beneath the nav-data download title once it is complete." + }, + "Processing" : { + "comment" : "Phase shown beneath the nav-data download title while importing." + }, + "Starting" : { + "comment" : "Phase shown beneath the nav-data download title before it begins." + }, + "Updating Navigation Data" : { + "comment" : "Title the system shows for the nav-data download while the app is in the background." + }, "±%@" : { }, @@ -1704,7 +1722,7 @@ "comment" : "A phrase that indicates that the requested operation is not", "isCommentAutoGenerated" : true }, - "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. Leave this app open until it finishes; %@ pauses the download if you switch away." : { + "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; %@ shows its progress and keeps it going." : { }, "Time Zone Display" : { diff --git a/SF50 TOLD/SF50_TOLDApp.swift b/SF50 TOLD/SF50_TOLDApp.swift index 4b16697f..9b98a1a2 100644 --- a/SF50 TOLD/SF50_TOLDApp.swift +++ b/SF50 TOLD/SF50_TOLDApp.swift @@ -68,6 +68,10 @@ struct SF50_TOLDApp: App { } init() { + // Registered while launching: the permitted identifiers are read once, and a handler offered + // afterwards is not matched against them. + NavDataDownloadTask.shared.registerHandler() + if ProcessInfo.processInfo.arguments.contains("UI-TESTING") { UITestingHelper.setupUITestingEnvironment() // Skip Sentry under UI tests: its profiling registers a CADisplayLink and diff --git a/SF50 TOLD/Views/Loading/LoadingConsentView.swift b/SF50 TOLD/Views/Loading/LoadingConsentView.swift index 4556e7d9..3de7b9c2 100644 --- a/SF50 TOLD/Views/Loading/LoadingConsentView.swift +++ b/SF50 TOLD/Views/Loading/LoadingConsentView.swift @@ -24,7 +24,7 @@ struct LoadingConsentView: View { .multilineTextAlignment(.center) Text( - "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. Leave this app open until it finishes; \(localizedModel()) pauses the download if you switch away." + "This process usually takes a few minutes. It must be done the first time the app launches, and approximately once a month as new navigation data is released. You can switch away while it runs; \(localizedModel()) shows its progress and keeps it going." ) .font(.footnote) .padding(.horizontal, 20) diff --git a/SF50-TOLD-Info.plist b/SF50-TOLD-Info.plist index 36e45e1e..5cfde273 100644 --- a/SF50-TOLD-Info.plist +++ b/SF50-TOLD-Info.plist @@ -2,42 +2,43 @@ - UIBackgroundModes - - fetch - - BGTaskSchedulerPermittedIdentifiers - - codes.tim.SF50-TOLD.refresh - - NOTAM_API_BASE_URL - $(NOTAM_API_BASE_URL) - NOTAM_API_TOKEN - $(NOTAM_API_TOKEN) - OPEN_METEO_API_KEY - $(OPEN_METEO_API_KEY) BAAppGroupID group.codes.tim.TOLD - BAHasManagedAssetPacks - - BAUsesAppleHosting - - BAManifestURL - https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/terrain-asset-packs-ios.json - BAMaxInstallSize - 9300000000 BAEssentialMaxInstallSize 0 + BAHasManagedAssetPacks + BAInitialDownloadRestrictions BADownloadAllowance 9300000000 - BAEssentialDownloadAllowance - 0 BADownloadDomainAllowList pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev + BAEssentialDownloadAllowance + 0 + BAManifestURL + https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/terrain-asset-packs-ios.json + BAMaxInstallSize + 9300000000 + BAUsesAppleHosting + + BGTaskSchedulerPermittedIdentifiers + + codes.tim.SF50-TOLD.refresh + codes.tim.SF50-TOLD.navdata-download + + NOTAM_API_BASE_URL + $(NOTAM_API_BASE_URL) + NOTAM_API_TOKEN + $(NOTAM_API_TOKEN) + OPEN_METEO_API_KEY + $(OPEN_METEO_API_KEY) + UIBackgroundModes + + fetch +