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
64 changes: 62 additions & 2 deletions SF50 TOLD/Loaders/NavDataLoader/NavDataLoaderViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BackgroundTasks
import Defaults
import os
import Observation
import SF50_Shared
import Sentry
Expand Down Expand Up @@ -40,6 +41,11 @@ final class NavDataLoaderViewModel: WithIdentifiableError {
private(set) var networkIsExpensive = false
private(set) var deferred = false

private let logger = Logger(
subsystem: "codes.tim.SF50-TOLD",
category: "NavDataLoaderViewModel"
)

private let container: ModelContainer
private let installer = NavDataStoreInstaller(layout: .appGroup)
private var cancellables: Set<Task<Void, Never>> = []
Expand Down Expand Up @@ -161,7 +167,6 @@ final class NavDataLoaderViewModel: WithIdentifiableError {

private func runLoad() async {
let generation = installer.reserveGeneration()
guard let loader = await makeLoader(generation: generation) else { return }

// 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.
Expand All @@ -172,11 +177,66 @@ final class NavDataLoaderViewModel: WithIdentifiableError {
backgroundTask?.expirationHandler = { [weak self] in
MainActor.assumeIsolated { self?.cancelLoad() }
}
defer { backgroundTask?.setTaskCompleted(success: error == nil) }

// A store built ahead of time turns minutes of assembling the database into a download. It is
// an optimization, not a dependency: anything that goes wrong falls back to importing the
// property list, which is still published and still works.
if await installPrebuiltStore(generation: generation, reportingTo: backgroundTask) { return }

await importPropertyList(generation: generation, reportingTo: backgroundTask)
}

/// Downloads and installs a store that was built ahead of time.
///
/// - Returns: Whether the app is now running on a prebuilt store.
private func installPrebuiltStore(
generation: Int,
reportingTo backgroundTask: BGContinuedProcessingTask?
) async -> Bool {
let (updates, continuation) = AsyncStream<NavDataLoader.State>.makeStream(
of: NavDataLoader.State.self,
bufferingPolicy: .bufferingNewest(1)
)
backgroundTask?.progress.totalUnitCount = Self.progressUnits
let mirror = Task { [weak self] in
for await update in updates where !Task.isCancelled {
guard let self else { return }
state = update
report(update, to: backgroundTask)
}
}
defer { mirror.cancel() }

do {
let manifest = try await PrebuiltNavDataStore().download(
to: StoreLayout.appGroup.navStoreURL(generation: generation),
reportingTo: continuation
)
continuation.finish()
try install(generation: generation)
Defaults[.ourAirportsLastUpdated] = manifest.ourAirportsLastUpdated
Defaults[.schemaVersion] = latestSchemaVersion
state = .finished
logger.notice("Installed the prebuilt store for cycle \(manifest.cycle, privacy: .public)")
return true
} catch {
continuation.finish()
logger.notice("Importing instead of using a prebuilt store: \(error.localizedDescription)")
StoreLayout.removeStore(at: StoreLayout.appGroup.navStoreURL(generation: generation))
return false
}
}

private func importPropertyList(
generation: Int,
reportingTo backgroundTask: BGContinuedProcessingTask?
) async {
guard let loader = await makeLoader(generation: generation) else { return }

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.
Expand Down
181 changes: 181 additions & 0 deletions SF50 TOLD/Loaders/NavDataLoader/PrebuiltNavDataStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import CryptoKit
import Foundation
import SF50_Shared
import SwiftNASR
import os

/// Fetches a nav-data store that was built ahead of time, instead of assembling one on the device.
///
/// The app used to spend minutes inserting a decoded property list into SwiftData before it could be
/// used at all. The same store is now built on a Mac once per cycle and published, so this downloads
/// and expands it — the work that remains is a transfer and a decompression.
///
/// Every step here is allowed to fail. A cycle that was never published, a store built against a
/// different schema, a truncated download: each falls back to importing the property list, which is
/// still published and still works. That is what makes this an optimization rather than a dependency.
actor PrebuiltNavDataStore {
/// Where published stores are served from.
///
/// The same bucket the terrain payloads use.
private static let manifestURLTemplate =
"https://pub-becd30c7b4e24860bee04cbbab788fb3.r2.dev/navdata/%@.json"

/// How many cycles back to look before giving up and importing instead.
///
/// One is enough to cover a publish that failed on the day a cycle took effect; more than that and
/// the data would be old enough that importing the current cycle is the better answer.
private static let cyclesToWalkBack = 2

private let logger = Logger(
subsystem: "codes.tim.SF50-TOLD",
category: "PrebuiltNavDataStore"
)

nonisolated private static func fetch(
from url: URL,
logger: Logger,
reportingTo continuation: AsyncStream<Float>.Continuation
) async throws -> URL {
defer { continuation.finish() }
let (fileURL, response) = try await downloadWithRetry(
from: url,
configuration: .ephemeral,
logger: logger,
label: "prebuilt nav data",
reportingTo: continuation
)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
try? FileManager.default.removeItem(at: fileURL)
throw Errors.noPublishedCycle
}
return fileURL
}

/// Hashes the downloaded payload, off this actor.
@concurrent
nonisolated private static func digest(of payload: URL) async throws -> String {
let data = try Data(contentsOf: payload, options: .mappedIfSafe)
return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}

/// Expands the compressed store, off this actor.
@concurrent
nonisolated private static func expand(_ payload: URL, to destination: URL) async throws {
let compressed = try Data(contentsOf: payload, options: .mappedIfSafe)
// swiftlint:disable:next legacy_objc_type
let store = try (compressed as NSData).decompressed(using: .lzma)
try (store as Data).write(to: destination, options: .atomic)
}

/// Downloads the newest published store this build can read, and expands it to `destination`.
///
/// Cycles are asked for by name and walked backwards, because a manifest states the window it is
/// effective for and a mutable "latest" pointer could not. A cycle whose publish failed therefore
/// costs the pilot the previous cycle's data rather than four weeks of no update at all.
///
/// - Parameters:
/// - destination: Where to write the expanded store.
/// - continuation: Yielded progress as the transfer and expansion proceed.
/// - Returns: The manifest of the cycle installed.
/// - Throws: ``Errors`` if nothing publishable could be found or read.
func download(
to destination: URL,
reportingTo continuation: AsyncStream<NavDataLoader.State>.Continuation
) async throws -> NavDataStoreManifest {
let manifest = try await newestReadableManifest()

continuation.yield(.downloading(progress: 0))
let payload = try await fetchStore(described: manifest, reportingTo: continuation)
defer { try? FileManager.default.removeItem(at: payload) }

continuation.yield(.extracting(progress: nil))
try await Self.expand(payload, to: destination)

return manifest
}

/// The newest published cycle whose store this build's schema can read.
///
/// Checked before anything is downloaded, let alone opened: SwiftData answers a near-miss schema
/// by migrating rather than by refusing, so a store opened first would be silently, slowly
/// migrated where a clean fall back to the import path was wanted.
private func newestReadableManifest() async throws -> NavDataStoreManifest {
var cycle: SwiftNASR.Cycle? = .effective
for _ in 0..<Self.cyclesToWalkBack {
guard let candidate = cycle else { break }
cycle = candidate.previous

guard let manifest = await manifest(for: "\(candidate)") else { continue }
guard manifest.schemaFingerprint == NavDataSchema.fingerprint,
manifest.schemaVersion == latestSchemaVersion
else {
logger.notice(
"""
Cycle \(manifest.cycle, privacy: .public) was built for a different store shape; \
importing instead
"""
)
throw Errors.schemaMismatch(
published: manifest.schemaFingerprint,
expected: NavDataSchema.fingerprint
)
}
return manifest
}
throw Errors.noPublishedCycle
}

/// The manifest for one cycle, or `nil` if it was never published.
private func manifest(for cycle: String) async -> NavDataStoreManifest? {
guard let url = URL(string: String(format: Self.manifestURLTemplate, cycle)) else { return nil }
do {
let (data, response) = try await URLSession(configuration: .ephemeral).data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
logger.info("No published store for cycle \(cycle, privacy: .public)")
return nil
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(NavDataStoreManifest.self, from: data)
} catch {
logger.notice("Couldn’t read the manifest for \(cycle, privacy: .public): \(error)")
return nil
}
}

/// Downloads the store a manifest describes, and checks it against the digest published for it.
private func fetchStore(
described manifest: NavDataStoreManifest,
reportingTo continuation: AsyncStream<NavDataLoader.State>.Continuation
) async throws -> URL {
let base = URL(string: String(format: Self.manifestURLTemplate, manifest.cycle))!
.deletingLastPathComponent()
let url = base.appending(path: manifest.store.filename)

let (progressUpdates, progress) = AsyncStream<Float>.makeStream(
of: Float.self,
bufferingPolicy: .bufferingNewest(1)
)
async let downloaded = Self.fetch(from: url, logger: logger, reportingTo: progress)
for await completed in progressUpdates { continuation.yield(.downloading(progress: completed)) }

let payload = try await downloaded
guard try await Self.digest(of: payload) == manifest.store.sha256 else {
try? FileManager.default.removeItem(at: payload)
throw Errors.digestMismatch
}
return payload
}

/// Reasons a prebuilt store could not be used.
enum Errors: Swift.Error {
/// No cycle in range had a manifest this build could read.
case noPublishedCycle

/// The published store was built for a different shape of store than this build reads.
case schemaMismatch(published: String, expected: String)

/// The download did not match the digest its manifest published.
case digestMismatch
}
}
2 changes: 1 addition & 1 deletion SF50 TOLD/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1722,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. You can switch away while it runs; %@ shows its progress and keeps it going." : {
"This 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
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. You can switch away while it runs; \(localizedModel()) shows its progress and keeps it going."
"This 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
Loading