Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions SF50 TOLD/Loaders/NavDataLoader/NavDataDownloadTask.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
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()
}
}
55 changes: 53 additions & 2 deletions SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BackgroundTasks
import Defaults
import Observation
import SF50_Shared
Expand Down Expand Up @@ -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)?

Expand Down Expand Up @@ -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? {
Expand All @@ -184,19 +209,45 @@ 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<Void, Never> {
private func observeProgress(
of loader: NavDataLoader,
reportingTo backgroundTask: BGContinuedProcessingTask?
) async -> Task<Void, Never> {
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 }

// 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,
Expand Down
20 changes: 19 additions & 1 deletion SF50 TOLD/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
"±%@" : {

},
Expand Down Expand Up @@ -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" : {
Expand Down
4 changes: 4 additions & 0 deletions SF50 TOLD/SF50_TOLDApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SF50 TOLD/Views/Loading/LoadingConsentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 25 additions & 24 deletions SF50-TOLD-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,43 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>codes.tim.SF50-TOLD.refresh</string>
</array>
<key>NOTAM_API_BASE_URL</key>
<string>$(NOTAM_API_BASE_URL)</string>
<key>NOTAM_API_TOKEN</key>
<string>$(NOTAM_API_TOKEN)</string>
<key>OPEN_METEO_API_KEY</key>
<string>$(OPEN_METEO_API_KEY)</string>
<key>BAAppGroupID</key>
<string>group.codes.tim.TOLD</string>
<key>BAHasManagedAssetPacks</key>
<true/>
<key>BAUsesAppleHosting</key>
<false/>
<key>BAManifestURL</key>
<string>https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/terrain-asset-packs-ios.json</string>
<key>BAMaxInstallSize</key>
<integer>9300000000</integer>
<key>BAEssentialMaxInstallSize</key>
<integer>0</integer>
<key>BAHasManagedAssetPacks</key>
<true/>
<key>BAInitialDownloadRestrictions</key>
<dict>
<key>BADownloadAllowance</key>
<integer>9300000000</integer>
<key>BAEssentialDownloadAllowance</key>
<integer>0</integer>
<key>BADownloadDomainAllowList</key>
<array>
<string>pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev</string>
</array>
<key>BAEssentialDownloadAllowance</key>
<integer>0</integer>
</dict>
<key>BAManifestURL</key>
<string>https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/terrain-asset-packs-ios.json</string>
<key>BAMaxInstallSize</key>
<integer>9300000000</integer>
<key>BAUsesAppleHosting</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>codes.tim.SF50-TOLD.refresh</string>
<string>codes.tim.SF50-TOLD.navdata-download</string>
</array>
<key>NOTAM_API_BASE_URL</key>
<string>$(NOTAM_API_BASE_URL)</string>
<key>NOTAM_API_TOKEN</key>
<string>$(NOTAM_API_TOKEN)</string>
<key>OPEN_METEO_API_KEY</key>
<string>$(OPEN_METEO_API_KEY)</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict>
</plist>
Loading