diff --git a/.gitignore b/.gitignore index 94a41dfcc..f4d7b5afe 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ user_config.json !specs/vectors/*.gpx # ...and the iOS companion's bundled GPX import fixture (three OBCKit tests load it). !companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/sample-import.gpx +!companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx # ...and the captured route-import provider exports, the canonical copy of the same bytes. !fixtures/sources/route-import/*.gpx !fixtures/sources/kandel-ride/*.gpx diff --git a/builder/app/dev-harness/ride-library.ts b/builder/app/dev-harness/ride-library.ts index b5cbb13ce..fcfdb99e6 100644 --- a/builder/app/dev-harness/ride-library.ts +++ b/builder/app/dev-harness/ride-library.ts @@ -62,6 +62,7 @@ function loopRide( const distanceM = Math.round(radiusDeg * 111_000 * 2 * Math.PI * 1.2); return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/dev-harness/simulated-device.svelte.ts b/builder/app/dev-harness/simulated-device.svelte.ts index de36bdfbe..b3a451988 100644 --- a/builder/app/dev-harness/simulated-device.svelte.ts +++ b/builder/app/dev-harness/simulated-device.svelte.ts @@ -284,6 +284,7 @@ function syntheticRide(name: string, startTime: number, points: number, sensors: } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/src/lib/device/library.test.ts b/builder/app/src/lib/device/library.test.ts index 418844ddc..87dc91383 100644 --- a/builder/app/src/lib/device/library.test.ts +++ b/builder/app/src/lib/device/library.test.ts @@ -84,6 +84,7 @@ function rideObject(name: string, startTime: number, points = 24): RideObject { } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/src/lib/device/rides.test.ts b/builder/app/src/lib/device/rides.test.ts index cc2fe2612..df1379a71 100644 --- a/builder/app/src/lib/device/rides.test.ts +++ b/builder/app/src/lib/device/rides.test.ts @@ -95,6 +95,7 @@ function rideFromTrackLog(log: Uint8Array, name: string, startTime: number): Rid } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, @@ -132,6 +133,7 @@ function longRide(points: number): RideObject { } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name: "Long Way Round", startTime: 1_783_598_400, @@ -231,6 +233,7 @@ describe("the ride object", () => { const ride = decodeRideObject(bytes); expect(ride).toMatchObject({ version: 6, + isDemo: true, name: "Sensor Ride", startTime: 1_751_460_000, distanceM: 12_345, @@ -262,6 +265,17 @@ describe("the ride object", () => { expect(encodeRideObject(ride)).toEqual(bytes); }); + it("keeps ordinary rides unmarked and rejects reserved footer flags", () => { + const bytes = encodeRideObject(longRide(1)); + const flags = bytes.length - 154 + 33; + expect(bytes[flags]).toBe(0); + expect(decodeRideObject(bytes).isDemo).toBe(false); + for (const reserved of [2, 0x80, 0xff]) { + bytes[flags] = reserved; + expect(() => decodeRideObject(bytes)).toThrow("non-canonical summary footer"); + } + }); + it("keeps a zero elevation as the device's 'no barometer yet' value", () => { const ride: RideObject = { ...longRide(1), diff --git a/builder/app/src/lib/usb/objects.ts b/builder/app/src/lib/usb/objects.ts index 714d072dc..8d1d2344e 100644 --- a/builder/app/src/lib/usb/objects.ts +++ b/builder/app/src/lib/usb/objects.ts @@ -75,6 +75,8 @@ export interface RideEffortLimits { /** A downloaded v6 ride: the recorded sample bytes followed by one fixed summary footer. */ export interface RideObject { version: 6; + /** Synthetic onboarding ride; preserved with the recorded payload. */ + isDemo: boolean; name: string; startTime: number; distanceM: number; @@ -110,7 +112,8 @@ export function decodeRideObject(data: Uint8Array): RideObject { const version = data[footer + 4]; if (version !== 6) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 6.`); const limits = footer + RIDE_LIMITS_AT; - if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0 || data[limits + 1] !== 0) { + const flags = data[footer + 33]; + if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || (flags & ~1) !== 0 || data[limits + 1] !== 0) { throw new ObjectDecodeError("ride object has a non-canonical summary footer."); } const name = footerName(data, footer + RIDE_NAME_AT, data[footer + 5]); @@ -149,6 +152,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { return { version: 6, + isDemo: (flags & 1) !== 0, points, name, startTime: view.getUint32(footer + 8, true), @@ -210,6 +214,7 @@ export function encodeRideObject(r: RideObject): Uint8Array { out[footer + 30] = r.avgHr ?? NO_U8; out[footer + 31] = r.maxHr ?? NO_U8; out[footer + 32] = r.avgCadence ?? NO_U8; + out[footer + 33] = r.isDemo ? 1 : 0; view.setUint16(footer + 34, r.avgPower ?? NO_U16, true); view.setUint16(footer + 36, r.maxPower ?? NO_U16, true); view.setUint32(footer + 38, r.energyKj ?? NO_U32, true); diff --git a/companion-ios/OBCCompanion/OBCCompanion.entitlements b/companion-ios/OBCCompanion/OBCCompanion.entitlements new file mode 100644 index 000000000..bb7269865 --- /dev/null +++ b/companion-ios/OBCCompanion/OBCCompanion.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:openbikecomputer.com + + + diff --git a/companion-ios/OBCCompanion/OBCCompanionApp.swift b/companion-ios/OBCCompanion/OBCCompanionApp.swift index db3a9f0ef..441c63d09 100644 --- a/companion-ios/OBCCompanion/OBCCompanionApp.swift +++ b/companion-ios/OBCCompanion/OBCCompanionApp.swift @@ -67,6 +67,8 @@ struct OBCCompanionApp: App { RootView( transport: Self.makeTransport(), bondStore: Self.makeBondStore(), + onboarding: Self.makeOnboardingProgress(), + updateChecker: Self.makeSetupUpdateChecker(), library: Self.makeLibraryStore(), photoLibrary: Self.makePhotoLibrary(), lastBikeType: Self.makeLastBikeTypeStore(), @@ -257,6 +259,20 @@ struct OBCCompanionApp: App { return PathMonitorReachability() } + static func makeOnboardingProgress() -> OnboardingProgress { + #if DEBUG + if mockControl != nil { return OnboardingProgress() } + #endif + return OnboardingProgress(defaults: .standard) + } + + static func makeSetupUpdateChecker() -> UpdateChecker? { + #if DEBUG + if let mockControl { return OnboardingFixtures.updateChecker(for: mockControl.scenario) } + #endif + return UpdateChecker() + } + /// The bond record behind the launch branch. Mock runs read it from the scenario, so the dev /// panel can replay first-run pairing; the real path persists it in `UserDefaults`. static func makeBondStore() -> any BondStore { diff --git a/companion-ios/OBCCompanion/RootView.swift b/companion-ios/OBCCompanion/RootView.swift index 6889c8b4d..9ddf6b3ea 100644 --- a/companion-ios/OBCCompanion/RootView.swift +++ b/companion-ios/OBCCompanion/RootView.swift @@ -4,13 +4,18 @@ import OBCTransport import OBCFormats import OBCUI -/// The app's root: the launch gate, a bond check and quiet reconnect or the pairing flow, in -/// front of the main screen, which pushes the detail screens. Holds only the seams the +/// The app's root: first-use setup or the Library, with a quiet reconnect for a saved bond. +/// The main screen pushes the detail screens. Holds only the seams the /// composition root chose, plus the file-format edge that turns a picked file into an import. /// The import flow itself is `ImportFlowModel`; this view only binds its presentation state. struct RootView: View { @State private var launchModel: LaunchFlowModel @State private var mainModel: MainScreenModel + @State private var onboarding: OnboardingProgress + @State private var onboardingFirmware: FirmwareUpdateModel + @State private var onboardingUpload: UploadRequest? + @State private var pendingSetupRoute: RouteID? + @State private var setupRouteError: String? @State private var importModel: ImportFlowModel /// The foreground-only link policy: a real background transition suspends the link, after /// draining any in-flight transfer; foreground re-raises it over the bonded reconnect path. @@ -50,6 +55,8 @@ struct RootView: View { init( transport: any DeviceTransport, bondStore: any BondStore, + onboarding: OnboardingProgress = OnboardingProgress(), + updateChecker: UpdateChecker? = nil, library: any LibraryStore = InMemoryLibraryStore(), photoLibrary: any PhotoLibrary = PhotoKitLibrary(), lastBikeType: LastBikeTypeStore = LastBikeTypeStore(), @@ -84,7 +91,15 @@ struct RootView: View { self.importer = importer let transferActivity = TransferActivity() self.transferActivity = transferActivity - _launchModel = State(initialValue: LaunchFlowModel(transport: transport, bondStore: bondStore)) + _onboarding = State(initialValue: onboarding) + _onboardingFirmware = State(initialValue: FirmwareUpdateModel( + transport: transport, deviceName: bondStore.load()?.deviceName ?? "your OBC", + activity: transferActivity, updateChecker: updateChecker + )) + _launchModel = State(initialValue: LaunchFlowModel( + transport: transport, bondStore: bondStore, + onboardingPending: { onboarding.isPending } + )) _lifecycleModel = State(initialValue: LinkLifecycleModel( transport: transport, activity: transferActivity, backgroundTasks: backgroundTasks )) @@ -116,13 +131,24 @@ struct RootView: View { _updateSurfaceModel = State(initialValue: UpdateSurfaceModel( transport: transport, bondStore: bondStore, - runner: UpdateSurfaceRunner(store: updateSurface), + runner: updateChecker.map { UpdateSurfaceRunner(checker: $0, store: updateSurface) }, notifier: updateNotifier )) } var body: some View { - LaunchFlowView(model: launchModel) { + LaunchFlowView(model: launchModel, setup: { + OnboardingFlowView( + progress: onboarding, firmware: onboardingFirmware, library: mainModel, + onDemoRoute: sendDemoRoute, + onImport: { urls in Task { await importModel.openFiles(at: urls) } }, + onUpdateAnswered: recordSetupUpdateAnswer, + onFinish: { + mainModel.tab = mainModel.rides.contains(where: \.isDemo) ? .tracked : .planned + launchModel.finishSetup() + } + ) + }) { NavigationStack(path: $path) { MainScreenView( model: mainModel, @@ -145,6 +171,8 @@ struct RootView: View { onOpenTrash: { path.append(.trash) }, + showsReadyNote: onboarding.showsReadyNote, + onDismissReadyNote: { onboarding.dismissReadyNote() }, onPlanRoute: plannerOpener ) // The main screen draws its own chrome, but the title still names the pop target. @@ -156,7 +184,12 @@ struct RootView: View { } // Everything below hangs outside the launch gate: a share can arrive before pairing, so // the import cover and its alert must present over the pairing flow too. - .fullScreenCover(item: $importModel.pendingImport) { pending in + .fullScreenCover(item: $importModel.pendingImport, onDismiss: { + if let id = pendingSetupRoute { + pendingSetupRoute = nil + sendSetupRoute(id) + } + }) { pending in importLanding(for: pending) } .fullScreenCover(item: $importModel.pendingJoin) { join in @@ -197,12 +230,12 @@ struct RootView: View { onSave: importModel.confirmNewName ) .task { + updateSurfaceModel.setEnabled(launchModel.phase == .main) lifecycleModel.start() reachability.start() // Remember the device's version while the link can be read, and run the launch check // once for this cold start; the `.active` edge below covers every return. - updateSurfaceModel.start() - updateSurfaceModel.appBecameActive() + if launchModel.phase == .main { startLibraryServices() } // A notice tapped from a cold launch: iOS delivers the response during startup, so // the flag may already be set by the time the first `.task` runs. if UpdateRouteRequest.shared.consume() { pushFirmwareUpdate() } @@ -223,7 +256,7 @@ struct RootView: View { // the way out. `.inactive` is deliberately neither. switch newPhase { case .active: - updateSurfaceModel.appBecameActive() + if launchModel.phase == .main { updateSurfaceModel.appBecameActive() } case .background: BackgroundUpdateRefresh.schedule() default: break @@ -231,9 +264,28 @@ struct RootView: View { } // Share-sheet delivery: iOS hands route files here, the same path as a Files pick. .onOpenURL { url in - // A share of several files arrives one URL at a time; the model batches them. - importModel.receive(url) + receive(url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { receive(url) } + } + .onChange(of: launchModel.phase) { _, phase in + updateSurfaceModel.setEnabled(phase == .main) + switch phase { + case .pairing: onboarding.begin() + case .setup: mainModel.start() + case .main: startLibraryServices() + default: break + } + } + .sheet(item: $onboardingUpload) { request in + UploadSheetView(model: request.model) } + .alert("Could not send the route", isPresented: Binding( + get: { setupRouteError != nil }, set: { if !$0 { setupRouteError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { Text(setupRouteError ?? "") } // One shared online and offline signal for every basemap preview. .environment(\.obcIsOnline, reachability.isOnline) // Hold the screen awake while any transfer is in flight: the idle-timer touch reads the @@ -259,12 +311,86 @@ struct RootView: View { } } + private func receive(_ url: URL) { + if LaunchFlowModel.acceptsPairingLink(url) { + launchModel.openPairingLink() + } else if url.isFileURL { + importModel.receive(url) + } + } + + private func startLibraryServices() { + updateSurfaceModel.setEnabled(true) + mainModel.start() + updateSurfaceModel.start() + updateSurfaceModel.appBecameActive() + } + + private func recordSetupUpdateAnswer(_ version: String) { + guard let serial = onboardingFirmware.deviceSerial, + let runningVersion = onboardingFirmware.runningVersion else { return } + let device = LastSeenDevice(serial: serial, firmwareVersion: runningVersion, seenAt: Date()) + let runner = UpdateSurfaceRunner(store: updateSurface) + runner.remember(device) + runner.recordAnswered(version: version, device: device) + } + + private func sendDemoRoute() { + do { + let data = try BundledDemoRoute.data() + let route = try importer.importRoute(from: data, fileExtension: "gpx") + let existing = library.plannedRoutes().first { + $0.sourceFileName == BundledDemoRoute.fileName && $0.sourceFileData == data && $0.route == route + } + if let existing { + sendSetupRoute(existing.id) + return + } + let detail = RouteDetailModel( + transport: transport, dressing: .imported(route, fileName: BundledDemoRoute.fileName) + ).makeDetail() + mainModel.addImportedRoute(PlannedRouteRecord( + summary: detail.summary, route: route, + sourceFileName: BundledDemoRoute.fileName, sourceFileData: data + )) + sendSetupRoute(detail.summary.id) + } catch { + setupRouteError = "The demo route could not be opened. You can import a GPX or TCX route, or skip this step." + } + } + + private func sendSetupRoute(_ id: RouteID) { + guard mainModel.connection == .connected, mainModel.connectedScope != nil, + mainModel.protocolMismatch == nil, + let summary = mainModel.routes.first(where: { $0.id == id }) else { + setupRouteError = "Keep your OBC on and nearby, then try again. Routes need compatible app and OBC versions." + return + } + let detail = RouteDetailModel( + transport: transport, dressing: .planned(summary), + bikeType: mainModel.plannedBikeType(for: id), + preloadedDetail: mainModel.importedDetail(for: id), + plannedGeometry: mainModel.plannedGeometry(for: id), + sourceFileName: mainModel.plannedSourceFileName(for: id), + deviceObjectID: mainModel.plannedDeviceObjectID(for: id), + provenCommittedCRC: mainModel.plannedProvenCommittedCRC(for: id) + ) + onboardingUpload = UploadRequest(model: UploadSheetModel( + transport: transport, blob: detail.makeUploadBlob(), deviceName: mainModel.deviceName, + timing: OBCCompanionApp.launchUploadTiming(), activity: transferActivity, + onCompleted: { objectID, crc in + if let objectID { mainModel.markRouteUploaded(id, objectID: objectID, crc32: crc) } + onboarding.move(to: .ride) + } + )) + } + // MARK: The proactive update surface /// Presentation binding for the launch sheet. Dismissal answers: a swipe-away says "not now". private var pendingUpdate: Binding { Binding( - get: { updateSurfaceModel.pending }, + get: { launchModel.phase == .main ? updateSurfaceModel.pending : nil }, set: { if $0 == nil { updateSurfaceModel.dismiss() } } ) } @@ -294,9 +420,15 @@ struct RootView: View { noDevicePaired: pending.noDevicePaired, // A trip is app-local, so the trip rows work with no device paired just the same. trips: mainModel.tripPickerItems, + isOnboarding: launchModel.phase == .setup && onboarding.stage == .route, replacing: pending.replacing, onSave: { detail, tripSelection in mainModel.addImportedRoute(pending.record(for: detail)) + if launchModel.phase == .setup, onboarding.stage == .route { + pendingSetupRoute = detail.summary.id + importModel.closeImport() + return + } // A trip choice moves the route into the trip and opens the trip page. // A new trip from one file opens in the day editor's split mode. if let tripID = mainModel.fileRoute(detail.summary.id, into: tripSelection) { @@ -522,12 +654,17 @@ struct RootView: View { // Bond cleared and link dropped by the model; pop the stack and show the pairing prompt. onForget: { path.removeAll() + onboarding.reset() launchModel.forgetDevice() }, // Its own destination, so the host owns a stable model and an in-flight transfer // survives Settings body passes. onOpenFirmwareUpdate: { path.append(.firmwareUpdate) }, - + onReplaySetup: { + path.removeAll() + onboarding.replay() + launchModel.replaySetup() + }, onOpenDevPanel: devPanelOpener ) case .firmwareUpdate: diff --git a/companion-ios/OBCCompanion/ScreenHosts.swift b/companion-ios/OBCCompanion/ScreenHosts.swift index 2cb14c5e1..5e41ecd66 100644 --- a/companion-ios/OBCCompanion/ScreenHosts.swift +++ b/companion-ios/OBCCompanion/ScreenHosts.swift @@ -12,6 +12,7 @@ import OBCUI struct SettingsScreen: View { @State private var model: SettingsModel private let onOpenFirmwareUpdate: () -> Void + private let onReplaySetup: () -> Void private let onOpenDevPanel: (() -> Void)? @@ -22,6 +23,7 @@ struct SettingsScreen: View { onDeviceRenamed: @escaping (String) -> Void, onForget: @escaping () -> Void, onOpenFirmwareUpdate: @escaping () -> Void, + onReplaySetup: @escaping () -> Void, onOpenDevPanel: (() -> Void)? ) { @@ -33,6 +35,7 @@ struct SettingsScreen: View { onForget: onForget )) self.onOpenFirmwareUpdate = onOpenFirmwareUpdate + self.onReplaySetup = onReplaySetup self.onOpenDevPanel = onOpenDevPanel } @@ -41,6 +44,7 @@ struct SettingsScreen: View { SettingsView( model: model, onOpenFirmwareUpdate: onOpenFirmwareUpdate, + onReplaySetup: onReplaySetup, onOpenDevPanel: onOpenDevPanel ) @@ -283,6 +287,7 @@ struct ImportLandingHost: View { private let noDevicePaired: Bool /// The trips the route can join, most recently edited first. private let trips: [TripPickerItem] + private let isOnboarding: Bool private let onSave: (RouteDetail, TripSelection) -> Void private let onPair: (RouteDetail) -> Void private let onCancel: () -> Void @@ -296,6 +301,7 @@ struct ImportLandingHost: View { deviceName: String, noDevicePaired: Bool, trips: [TripPickerItem] = [], + isOnboarding: Bool = false, // When this import replaces an existing route, the landing reuses its id, so New route // updates that route in place instead of adding a duplicate. replacing: PlannedRouteRecord? = nil, @@ -312,6 +318,7 @@ struct ImportLandingHost: View { self.deviceName = deviceName self.noDevicePaired = noDevicePaired self.trips = trips + self.isOnboarding = isOnboarding self.onSave = onSave self.onPair = onPair self.onCancel = onCancel @@ -334,30 +341,32 @@ struct ImportLandingHost: View { /// The one save, then the two ways into a trip. Each choice says where the route lands. private var rows: some View { VStack(alignment: .leading, spacing: 20) { - Button("Save to Library") { save(.none) } + Button(isOnboarding ? "Save and send to OBC" : "Save to Library") { save(.none) } .buttonStyle(.obcPrimary) .accessibilityIdentifier("import.newRoute") - OBCGroupedSection("Or put it in a trip") { - if trips.count == 1, let trip = trips.first { - OBCListRow(label: "Add to \(trip.name)", detail: "It becomes day \(trip.dayCount + 1).", showsChevron: true) { - save(.existing(trip.id)) + if !isOnboarding { + OBCGroupedSection("Or put it in a trip") { + if trips.count == 1, let trip = trips.first { + OBCListRow(label: "Add to \(trip.name)", detail: "It becomes day \(trip.dayCount + 1).", showsChevron: true) { + save(.existing(trip.id)) + } + .accessibilityIdentifier("import.addToTrip") + } else if trips.count > 1 { + OBCListRow(label: "Add to a trip", detail: "It becomes the last day.", showsChevron: true) { + tripPickerShown = true + } + .accessibilityIdentifier("import.addToTrip") } - .accessibilityIdentifier("import.addToTrip") - } else if trips.count > 1 { - OBCListRow(label: "Add to a trip", detail: "It becomes the last day.", showsChevron: true) { - tripPickerShown = true + OBCListRow( + label: "Start a new trip", + detail: "Then split the route into days.", + showsChevron: true, + showsDivider: false + ) { + save(.new(model.name)) } - .accessibilityIdentifier("import.addToTrip") + .accessibilityIdentifier("import.startTrip") } - OBCListRow( - label: "Start a new trip", - detail: "Then split the route into days.", - showsChevron: true, - showsDivider: false - ) { - save(.new(model.name)) - } - .accessibilityIdentifier("import.startTrip") } } } diff --git a/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift new file mode 100644 index 000000000..ec6ea6245 --- /dev/null +++ b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift @@ -0,0 +1,112 @@ +import XCTest + +final class OnboardingBranchesTests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + private func launch(_ scenario: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-OBCScenario", scenario, "-OBCNetwork", "offline", + "-OBCHideMockHUD", "-OBCDisableAnimations", "-OBCHoldConfirmations", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US" + ] + app.launchEnvironment["TZ"] = "UTC" + app.launch() + tap(app, "onboarding.getStarted") + tap(app, "pair.start") + tap(app, "onboarding.allowBluetooth") + return app + } + + @MainActor + private func tap(_ app: XCUIApplication, _ identifier: String) { + let button = app.buttons[identifier] + XCTAssertTrue(button.waitForExistence(timeout: 15), identifier) + let enabled = expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: button) + wait(for: [enabled], timeout: 15) + button.tap() + } + + @MainActor + private func reachUpdate(_ app: XCUIApplication) { + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + tap(app, "pairing.keepName") + tap(app, "onboarding.sensorsContinue") + XCTAssertTrue(app.staticTexts["onboarding.updateTitle"].waitForExistence(timeout: 15)) + } + + @MainActor + private func capture(_ app: XCUIApplication, _ name: String) { + var previous = app.screenshot() + for _ in 0..<40 { + let next = app.screenshot() + if previous.pngRepresentation == next.pngRepresentation { + let attachment = XCTAttachment(screenshot: next) + attachment.name = "onboarding-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + return + } + previous = next + } + XCTFail("\(name) did not settle") + } + + @MainActor + func testSeveralOBCsRequireSelectingTheMatchingName() { + let app = launch("onboardingNearby") + let first = "pair.deviceRow.00000000-0000-0000-0000-000000000001" + let second = "pair.deviceRow.00000000-0000-0000-0000-000000000002" + XCTAssertTrue(app.buttons[first].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons[second].exists) + XCTAssertTrue(app.buttons[first].label.contains("OBC-7A2F")) + XCTAssertTrue(app.buttons[second].label.contains("OBC-9C41")) + XCTAssertFalse(app.staticTexts["pair.pairedTitle"].exists) + capture(app, "P04-choose-your-OBC") + tap(app, second) + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.textFields["pairing.name"].value as? String, "OBC-9C41") + capture(app, "P06-selected-OBC") + tap(app, "pairing.keepName") + XCTAssertTrue(app.staticTexts["onboarding.sensorsTitle"].waitForExistence(timeout: 10)) + } + + @MainActor + func testCompatibleUpdateCanWaitUntilLater() { + let app = launch("onboardingUpdate") + reachUpdate(app) + XCTAssertTrue(app.staticTexts["v0.4.0 → v0.5.0"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.staticTexts["onboarding.updateTitle"].label, "Update your OBC") + XCTAssertTrue(app.buttons["onboarding.updateNow"].exists) + XCTAssertEqual(app.buttons["onboarding.updateLater"].label, "Later") + capture(app, "P09-update-or-later") + tap(app, "onboarding.updateLater") + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 10)) + tap(app, "onboarding.routeSkip") + tap(app, "onboarding.finish") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["firmware.updateSheet.view"].exists) + } + + @MainActor + func testIncompatibleFirmwareCanFinishSetupWithoutTransfers() { + let app = launch("onboardingUpdateNeeded") + reachUpdate(app) + let title = app.staticTexts["onboarding.updateTitle"] + let mismatch = expectation( + for: NSPredicate(format: "label == 'An update is needed'"), evaluatedWith: title) + wait(for: [mismatch], timeout: 15) + XCTAssertTrue(app.staticTexts[ + "Routes and ride sync need newer OBC firmware. Riding still works, and you can finish setup now." + ].exists) + XCTAssertEqual(app.buttons["onboarding.updateLater"].label, "Finish setup") + capture(app, "P13-update-needed") + tap(app, "onboarding.updateLater") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["onboarding.routeTitle"].exists) + XCTAssertFalse(app.staticTexts["onboarding.rideTitle"].exists) + } +} diff --git a/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift new file mode 100644 index 000000000..b5356e82b --- /dev/null +++ b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift @@ -0,0 +1,103 @@ +import XCTest + +final class OnboardingFlowTests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-OBCScenario", "onboarding", "-OBCNetwork", "offline", + "-OBCHideMockHUD", "-OBCDisableAnimations", "-OBCHoldConfirmations", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US" + ] + app.launchEnvironment["TZ"] = "UTC" + app.launch() + return app + } + + @MainActor + private func capture(_ app: XCUIApplication, _ name: String) { + var previous = app.screenshot() + for _ in 0..<40 { + let next = app.screenshot() + if previous.pngRepresentation == next.pngRepresentation { + let attachment = XCTAttachment(screenshot: next) + attachment.name = "onboarding-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + return + } + previous = next + } + XCTFail("\(name) did not settle") + } + + @MainActor + private func tap(_ app: XCUIApplication, _ identifier: String) { + let button = app.buttons[identifier] + XCTAssertTrue(button.waitForExistence(timeout: 15), identifier) + let enabled = expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: button) + wait(for: [enabled], timeout: 15) + button.tap() + } + + @MainActor + func testDemoRouteAndRideReachLibrary() { + let app = launch() + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + capture(app, "P01-welcome") + tap(app, "onboarding.getStarted") + capture(app, "P02-switch-on") + tap(app, "pair.start") + capture(app, "P03-bluetooth") + tap(app, "onboarding.allowBluetooth") + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.textFields["pairing.name"].value as? String, "OBC-7A2F") + capture(app, "P06-factory-name") + tap(app, "pairing.keepName") + capture(app, "P08-sensors") + tap(app, "onboarding.sensorsContinue") + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 15)) + capture(app, "P10-route") + tap(app, "onboarding.demoRoute") + XCTAssertTrue(app.buttons["upload.done"].waitForExistence(timeout: 30)) + XCTAssertTrue(app.staticTexts["Grimsel Pass is on OBC-7A2F"].exists) + capture(app, "P10-route-sent") + tap(app, "upload.done") + XCTAssertTrue(app.staticTexts["onboarding.rideTitle"].waitForExistence(timeout: 10)) + capture(app, "P11-ride") + tap(app, "onboarding.syncRide") + let title = app.staticTexts["onboarding.rideTitle"] + let synced = expectation( + for: NSPredicate(format: "label == 'Your demo ride is here'"), evaluatedWith: title) + wait(for: [synced], timeout: 30) + capture(app, "P11-ride-synced") + tap(app, "onboarding.finish") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + capture(app, "P12-ready-note") + tap(app, "Got it") + XCTAssertFalse(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) + let demo = app.staticTexts["Grimsel Pass"].firstMatch + for _ in 0..<3 where !demo.exists { app.swipeUp() } + XCTAssertTrue(demo.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts.containing( + NSPredicate(format: "label CONTAINS %@", "Demo ride, excluded from totals") + ).firstMatch.exists) + capture(app, "P12-demo-library") + } + + @MainActor + func testBrowseThenReplaySetupFromSettings() { + let app = launch() + tap(app, "onboarding.browse") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + tap(app, "topbar.settings") + let replay = app.buttons["settings.replaySetup"] + if !replay.isHittable { app.swipeUp() } + tap(app, "settings.replaySetup") + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + } +} diff --git a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift index 3719623db..19c2b1845 100644 --- a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift +++ b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift @@ -12,7 +12,7 @@ final class PairingFlowTests: XCTestCase { @MainActor private func launch(scenario: String) -> XCUIApplication { let app = XCUIApplication() - app.launchArguments += ["-OBCScenario", scenario] + app.launchArguments += ["-OBCScenario", scenario, "-OBCHideMockHUD", "-OBCDisableAnimations"] app.launch() return app } @@ -26,25 +26,41 @@ final class PairingFlowTests: XCTestCase { add(attachment) } + @MainActor + private func enterPairing(_ app: XCUIApplication, capture: Bool = false) { + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P01-welcome") } + app.buttons["onboarding.getStarted"].tap() + XCTAssertTrue(app.staticTexts["onboarding.switchOnTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P02-switch-on") } + app.buttons["pair.start"].tap() + XCTAssertTrue(app.staticTexts["onboarding.bluetoothTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P03-bluetooth") } + app.buttons["onboarding.allowBluetooth"].tap() + } + /// The intro, the scan with the row sliding in, the pairing beat, then the main screen. @MainActor func testFirstRunPairingHappyPath() { - let app = launch(scenario: "noDevice") + let app = launch(scenario: "onboarding") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10), "D1 missing") - snap(app, "D1-pairing-prompt") - app.buttons["pair.start"].tap() - - let row = app.buttons["pair.deviceRow"] - XCTAssertTrue(row.waitForExistence(timeout: 10), "D2 discovered row missing") - snap(app, "D2-scanning-found") - row.tap() + enterPairing(app, capture: true) XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 10), "D4 missing") - snap(app, "D4-paired") - app.buttons["pair.goToRoutes"].tap() - - XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "main missing after pairing") + snap(app, "onboarding-P06-paired") + app.buttons["pairing.keepName"].tap() + XCTAssertTrue(app.staticTexts["onboarding.sensorsTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P08-sensors") + app.buttons["onboarding.sensorsContinue"].tap() + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P10-route") + app.buttons["onboarding.routeSkip"].tap() + XCTAssertTrue(app.staticTexts["onboarding.rideTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P11-ride") + app.buttons["onboarding.finish"].tap() + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "main missing after setup") + XCTAssertTrue(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) + snap(app, "onboarding-P12-library") } /// A scan timeout resolves to the failure screen; Try again loops back through scanning. @@ -52,8 +68,7 @@ final class PairingFlowTests: XCTestCase { func testPairingTimeoutShowsD5AndRetryLoops() { let app = launch(scenario: "pairingTimeout") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let failed = app.staticTexts["pair.failedTitle"] XCTAssertTrue(failed.waitForExistence(timeout: 10), "D5 missing") @@ -68,14 +83,7 @@ final class PairingFlowTests: XCTestCase { func testPairingRejectedShowsD5RejectedCopy() { let app = launch(scenario: "pairingRejected") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() - - // The row appears first, from un-gated discovery. The passkey is gated and only fires on - // the row tap, so a rejection surfaces after confirming, not before. - let row = app.buttons["pair.deviceRow"] - XCTAssertTrue(row.waitForExistence(timeout: 10), "D2 discovered row missing") - row.tap() + enterPairing(app) let failed = app.staticTexts["pair.failedTitle"] XCTAssertTrue(failed.waitForExistence(timeout: 10), "D5 missing") @@ -87,8 +95,7 @@ final class PairingFlowTests: XCTestCase { func testBluetoothOffShowsH8AndLibraryStaysReachable() { let app = launch(scenario: "bluetoothOff") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let title = app.staticTexts["radio.title"] XCTAssertTrue(title.waitForExistence(timeout: 10), "H8 missing") @@ -104,8 +111,7 @@ final class PairingFlowTests: XCTestCase { func testPermissionDeniedShowsH7State() { let app = launch(scenario: "permissionDenied") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let title = app.staticTexts["radio.title"] XCTAssertTrue(title.waitForExistence(timeout: 10), "H7 state missing") @@ -131,8 +137,7 @@ final class PairingFlowTests: XCTestCase { snap(app, "S4-main-out-of-range") } - /// Bonded but with the link down at launch: the connecting state resolves to the main screen - /// within the grace window. + /// A saved pairing opens the Library while the link connects in the background. @MainActor func testBondedColdLaunchResolvesToMain() { let app = XCUIApplication() @@ -141,26 +146,22 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 15)) } - /// Bonded but the device never answers: the grace window expires onto the connect-failed - /// screen, never a forever-spinner, and the secondary action still reaches the library. + /// An absent device leaves the Library available, with connection status in its header. @MainActor - func testDeviceUnreachableTimesOutToConnectFailedAndRoutesStayReachable() { + func testDeviceUnreachableOpensLibraryWhileReconnecting() { let app = launch(scenario: "deviceUnreachable") - - XCTAssertTrue(app.staticTexts["launch.connectingTitle"].waitForExistence(timeout: 10), "A state missing") - // The default connect grace must expire onto the timeout screen. - let title = app.staticTexts["launch.connectFailedTitle"] - XCTAssertTrue(title.waitForExistence(timeout: 15), "connect-failed screen missing") - XCTAssertEqual(title.label, "Can't reach Trailhead") - XCTAssertTrue(app.buttons["launch.tryAgain"].exists) - snap(app, "A-timeout-connect-failed") - - // Try again re-enters the connecting state, and the still-silent device times out again. - app.buttons["launch.tryAgain"].tap() - XCTAssertTrue(app.staticTexts["launch.connectingTitle"].waitForExistence(timeout: 10), "retry must re-enter A") - XCTAssertTrue(title.waitForExistence(timeout: 15), "second timeout missing") - - app.buttons["launch.goToRoutes"].tap() XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "library must stay reachable") + let header = app.descendants(matching: .any)["topbar.device"].firstMatch + let reconnecting = expectation( + for: NSPredicate(format: "label CONTAINS[c] 'connecting'"), evaluatedWith: header) + wait(for: [reconnecting], timeout: 10) + XCTAssertFalse(app.buttons["topbar.sync"].isEnabled) + XCTAssertTrue(app.buttons["topbar.settings"].isEnabled) + XCTAssertFalse(app.staticTexts["launch.connectingTitle"].exists) + XCTAssertFalse(app.staticTexts["launch.connectFailedTitle"].exists) + app.segmentedControls.buttons["Rides"].tap() + XCTAssertTrue(app.staticTexts["No rides yet"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any)["main.readError"].firstMatch.exists) + snap(app, "D5-library-offline") } } diff --git a/companion-ios/Packages/OBCKit/Package.swift b/companion-ios/Packages/OBCKit/Package.swift index 4c0d15739..bf9141c0f 100644 --- a/companion-ios/Packages/OBCKit/Package.swift +++ b/companion-ios/Packages/OBCKit/Package.swift @@ -53,11 +53,12 @@ let package = Package( .target( name: "OBCFormats", dependencies: ["OBCDomain"], + resources: [.process("Resources")], swiftSettings: languageMode ), .target( name: "OBCMock", - dependencies: ["OBCTransport", "OBCDomain"], + dependencies: ["OBCTransport", "OBCDomain", "OBCFormats"], // Editable JSON fixture sets (routes/rides/config/diagnostics) the mock // serves. The Swift that loads them is `#if DEBUG`; these are inert data. resources: [.process("Fixtures")], diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift index 4fd182f2e..aeb157a00 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift @@ -156,6 +156,8 @@ public struct Ride: Identifiable, Equatable, Sendable { } public struct RideSummary: Identifiable, Equatable, Sendable { + /// A factory sample ride, shown in the library but excluded from totals. + public var isDemo: Bool public var source: RideSource? public let id: RideID public var name: String @@ -211,7 +213,8 @@ public struct RideSummary: Identifiable, Equatable, Sendable { bikeType: BikeType = .road, trip: RideTrip? = nil, zoneLimits: RideZoneLimits = .notSet, - source: RideSource? = nil + source: RideSource? = nil, + isDemo: Bool = false ) { self.id = id self.name = name @@ -232,6 +235,7 @@ public struct RideSummary: Identifiable, Equatable, Sendable { self.trip = trip self.zoneLimits = zoneLimits self.source = source + self.isDemo = isDemo } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift index f2975f982..ef05a5cb9 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift @@ -78,7 +78,8 @@ public enum RideEdit { /// starts within `maxGapMeters` and `maxGapSeconds` of where the first ended, on the same /// trip day or both without a trip. public static func suggestsMerge(_ first: Ride, _ second: Ride) -> Bool { - guard let end = first.points.last, let start = second.points.first else { return false } + guard first.summary.isDemo == second.summary.isDemo, + let end = first.points.last, let start = second.points.first else { return false } let gap = start.timestamp.timeIntervalSince(end.timestamp) guard gap >= 0, gap <= maxGapSeconds, end.coordinate.routeDistance(to: start.coordinate) <= maxGapMeters @@ -165,7 +166,7 @@ extension RideSummary { climbMeters: climb, descentMeters: descent, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: heartRate.average, maxHeartRate: heartRate.max, avgCadence: cadence.average, avgPower: power.average, maxPower: power.max, energyKJ: joules.map { Int($0 / 1_000) }, - bikeType: bikeType, trip: trip, zoneLimits: zoneLimits + bikeType: bikeType, trip: trip, zoneLimits: zoneLimits, isDemo: isDemo ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift index 2181fa81e..f4ba0a530 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift @@ -30,7 +30,7 @@ public struct RideFilter: Equatable, Sendable { } /// Sums over rides. The input is the rides as the list shows them, so an edited ride counts once, -/// with its edited values and bike type. +/// with its edited values and bike type. Factory demo rides do not count. public struct RideTotals: Equatable, Sendable { public var rideCount = 0 public var distanceMeters = 0.0 @@ -39,7 +39,7 @@ public struct RideTotals: Equatable, Sendable { public var climbMeters = 0.0 public init(_ rides: some Sequence) { - for ride in rides { + for ride in rides where !ride.isDemo { rideCount += 1 distanceMeters += ride.distanceMeters movingTime += ride.movingTime @@ -52,7 +52,7 @@ public struct RideTotals: Equatable, Sendable { public static func byBikeType( _ rides: some Sequence ) -> [(bikeType: BikeType, totals: RideTotals)] { - let groups = Dictionary(grouping: rides, by: \.bikeType) + let groups = Dictionary(grouping: rides.filter { !$0.isDemo }, by: \.bikeType) return BikeType.allCases.compactMap { type in groups[type].map { (type, RideTotals($0)) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift b/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift new file mode 100644 index 000000000..e0461408e --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum BundledDemoRoute { + public static let fileName = "grimsel-pass.gpx" + + public static func data() throws -> Data { + guard let url = Bundle.module.url(forResource: "grimsel-pass", withExtension: "gpx") else { + throw CocoaError(.fileNoSuchFile) + } + return try Data(contentsOf: url) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx b/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx similarity index 100% rename from companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx rename to companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift index 7b72616fd..0d9672109 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift @@ -2,6 +2,7 @@ import Foundation import OBCDomain import OBCTransport +import OBCFormats /// A loaded fixture set: the domain objects the mock serves. A value type, so the live /// `MockControl` can copy-mutate it under its lock. Built by decoding editable JSON in @@ -187,6 +188,15 @@ extension FixtureSet { /// without the planned route, so an import can share that launch without a collision. Missing /// or unreadable resources fall back to `builtIn`: the mock never traps. public static func load(_ named: String) -> FixtureSet { + if named == "onboarding" { + var fixtures = load("website-rides") + fixtures.deviceInfo = fixtures.deviceInfo.renamed("OBC-7A2F") + fixtures.config.name = "OBC-7A2F" + for index in fixtures.rides.indices { + fixtures.rides[index].summary.isDemo = true + } + return fixtures + } if named == "website-rides" { var fixtures = load("website") fixtures.routes = [] @@ -238,7 +248,7 @@ public enum SampleRouteFile { case .gpx, .tcx: bundled("sample-import", kind.rawValue).map { [($0, "sample-import.\(kind.rawValue)")] } ?? [] case .grimsel: - bundled("website-import", "gpx").map { [($0, "website-import.gpx")] } ?? [] + (try? BundledDemoRoute.data()).map { [($0, BundledDemoRoute.fileName)] } ?? [] case .bad: [(Data("socks · stove · sleeping bag — definitely not a route\n".utf8), "packing-list.pdf")] case .trip: diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift index b23581dfb..05228ae09 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift @@ -45,6 +45,7 @@ public final class MockControl: @unchecked Sendable { private var _latency: Duration private var _throughput: Int private var _radio: RadioState + private var _pairingDevices: [PairingDevice]? private var _bonded: Bool private var _bondedName: String? private var _pairingFail: PairingFail? @@ -92,6 +93,7 @@ public final class MockControl: @unchecked Sendable { self._dropFraction = preset.dropAtFraction self._supportsClockSync = preset.supportsClockSync self._fixtures = fixtures + OnboardingFixtures.configure(self) } /// Start from `happyPath` but override the reported device identity. @@ -151,6 +153,12 @@ public final class MockControl: @unchecked Sendable { set { lock.withLocked { _bondedName = newValue } } } + /// Nil uses the fixture device. An empty list models a scan with no nearby OBC. + public var pairingDevices: [PairingDevice]? { + get { lock.withLocked { _pairingDevices } } + set { lock.withLocked { _pairingDevices = newValue } } + } + public var deviceInfo: DeviceInfo { get { lock.withLocked { _fixtures.deviceInfo } } set { lock.withLocked { _fixtures.deviceInfo = newValue } } @@ -174,6 +182,7 @@ public final class MockControl: @unchecked Sendable { _radio = preset.radio _bonded = preset.bonded _bondedName = nil + _pairingDevices = nil _pairingFail = preset.pairingFail _pendingFailures = preset.pendingFailure.map { [$0] } ?? [] _dropFraction = preset.dropAtFraction @@ -181,6 +190,7 @@ public final class MockControl: @unchecked Sendable { _setClockSamples = [] _fixtures = fixtures } + OnboardingFixtures.configure(self) stateMulticast.send(preset.connection) batteryMulticast.send(fixtures.battery) } @@ -628,7 +638,7 @@ public final class MockControl: @unchecked Sendable { deviceInfo = DeviceInfo( name: current.name, firmwareVersion: version, hardwareVersion: current.hardwareVersion, serial: current.serial, - protocolVersion: current.protocolVersion, + protocolVersion: scenario == .onboardingUpdateNeeded ? OBCProtocol.version : current.protocolVersion, // Firmware replacement keeps the mounted store's identity. storeID: current.storeID, obcmVersion: current.obcmVersion @@ -725,7 +735,7 @@ extension NSLock { } extension DeviceInfo { - fileprivate func renamed(_ name: String) -> DeviceInfo { + func renamed(_ name: String) -> DeviceInfo { DeviceInfo(name: name, firmwareVersion: firmwareVersion, hardwareVersion: hardwareVersion, serial: serial, protocolVersion: protocolVersion, storeID: storeID, obcmVersion: obcmVersion) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift index d459cf6f8..b843f9fd8 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift @@ -40,9 +40,33 @@ public struct MockTransport: DeviceTransport { try await authenticate() } + public func scanForPairing() async throws -> [PairingDevice] { + await control.delay() + try Task.checkCancellation() + try control.radioGate() + try control.takePendingFailure() + let name = control.deviceInfo.name + return control.pairingDevices ?? [PairingDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: name.hasPrefix("OBC-") ? name : "OBC-\(name)" + )] + } + + public func discover(_ candidate: PairingDevice) async throws { + let candidates = try await scanForPairing() + guard candidates.contains(where: { $0.id == candidate.id }) else { throw DeviceError.deviceNotFound } + try await discover() + if control.pairingDevices != nil { + var config = control.fixtures.config + config.name = candidate.name + control.setConfig(config) + } + } + public func discover() async throws { control.connection = .connecting await control.delay() + try Task.checkCancellation() do { try control.radioGate() try control.takePendingFailure() @@ -55,6 +79,7 @@ public struct MockTransport: DeviceTransport { public func authenticate() async throws { // The pairing gate stands in for the real path's LESC passkey sheet. await control.delay() + try Task.checkCancellation() do { try control.pairingGate() } catch { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift new file mode 100644 index 000000000..f1bd6b1d3 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift @@ -0,0 +1,53 @@ +#if DEBUG +import Foundation +import OBCDomain +import OBCTransport + +/// Named first-use branches exercise the real pairing and firmware models with local data. +public enum OnboardingFixtures { + static func configure(_ control: MockControl) { + switch control.scenario { + case .onboardingNearby: + control.pairingDevices = [ + PairingDevice(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "OBC-7A2F"), + PairingDevice(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "OBC-9C41") + ] + case .onboardingUpdate, .onboardingUpdateNeeded: + let info = control.deviceInfo + control.deviceInfo = DeviceInfo( + name: info.name, firmwareVersion: "0.4.0", hardwareVersion: info.hardwareVersion, + serial: info.serial, + protocolVersion: control.scenario == .onboardingUpdateNeeded ? OBCProtocol.version - 1 : OBCProtocol.version, + storeID: info.storeID, obcmVersion: info.obcmVersion) + default: break + } + } + + /// Both the cached offer and an explicit recheck/download stay in memory. The reserved URLs + /// identify fixture objects; no request leaves this fetcher, and no release-notes link is shown. + public static func updateChecker(for scenario: Scenario) -> UpdateChecker? { + guard scenario == .onboardingUpdate || scenario == .onboardingUpdateNeeded else { return nil } + let payload = SampleFirmwareFile.container(version: "0.5.0") + let release = FirmwareRelease( + version: "0.5.0", bytes: payload.count, sha256: UpdateChecker.sha256Hex(payload), + url: URL(string: "https://example.invalid/onboarding/UPDATE.BIN")!) + let fetcher = OnboardingFirmwareFetcher(release: release, payload: payload) + return UpdateChecker( + manifestURL: OnboardingFirmwareFetcher.manifestURL, + fetcher: fetcher, + store: InMemoryUpdateCheckStore(record: UpdateCheckRecord(release: release, checkedAt: Date()))) + } +} + +private struct OnboardingFirmwareFetcher: ManifestFetching { + static let manifestURL = URL(string: "https://example.invalid/onboarding/manifest.json")! + let release: FirmwareRelease + let payload: Data + + func get(_ url: URL) async throws -> (status: Int, body: Data) { + if url == release.url { return (200, payload) } + if url == Self.manifestURL { return (200, try JSONEncoder().encode(release)) } + return (404, Data()) + } +} +#endif diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift index 00bdacd68..fa40164e8 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift @@ -16,6 +16,10 @@ public enum Scenario: String, CaseIterable, Sendable { case outOfRange case deviceUnreachable case noDevice + case onboarding + case onboardingNearby + case onboardingUpdate + case onboardingUpdateNeeded case pairingTimeout case pairingRejected case bluetoothOff @@ -90,6 +94,8 @@ extension Scenario { // Bonded but the device never answers: connect() parks on the huge latency the // way a real scan parks on an absent peripheral. Launch must time out, not hang. return ScenarioPreset(connection: .disconnected, latency: .seconds(3_600)) + case .onboarding, .onboardingNearby, .onboardingUpdate, .onboardingUpdateNeeded: + return ScenarioPreset(fixtures: "onboarding", connection: .disconnected, bonded: false) case .noDevice: return ScenarioPreset(connection: .disconnected, bonded: false) case .pairingTimeout: diff --git a/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift b/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift index 71b62e61e..fe3bd0870 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift @@ -58,7 +58,7 @@ public actor TransferClient { } public func list(kind: ObjectKind? = nil) async throws -> [CatalogEntry] { - await acquire() + try await acquire() defer { release() } do { return try await listAll(kind: kind).entries } catch is TransferLinkLost { @@ -68,7 +68,7 @@ public actor TransferClient { } public func catalog(kind: ObjectKind? = nil) async throws -> (storeID: StoreID, entries: [CatalogEntry]) { - await acquire() + try await acquire() defer { release() } do { return try await listAll(kind: kind) } catch is TransferLinkLost { @@ -81,7 +81,7 @@ public actor TransferClient { /// A BLE page carries only two entries at the preferred MTU, so `catalog()` would cost /// hundreds of needless control writes for one StoreId. public func storeID() async throws -> StoreID { - await acquire() + try await acquire() defer { release() } do { return try await identifyStore() } catch is TransferLinkLost { @@ -92,7 +92,7 @@ public actor TransferClient { } public func status(objectID: ObjectID, revision: Revision) async throws -> StatusResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() do { return try await statusOnLiveLink(objectID: objectID, revision: revision) } @@ -106,7 +106,7 @@ public actor TransferClient { objectID: ObjectID, revision: Revision? = nil, expectedStoreID: StoreID? = nil, progress: @escaping @Sendable (_ bytesDone: Int, _ total: Int) -> Void = { _, _ in } ) async throws -> (result: GetResult, payload: Data) { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() if let expectedStoreID { try await checkStore(expectedStoreID) } @@ -137,7 +137,7 @@ public actor TransferClient { kind: ObjectKind, displayName: String, progress: @escaping @Sendable (_ bytesDone: Int, _ total: Int) -> Void = { _, _ in } ) async throws -> PutResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let crc = CRC32.checksum(payload) @@ -193,7 +193,7 @@ public actor TransferClient { } public func remove(objectID: ObjectID, expectedRevision: Revision) async throws -> RemoveResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() do { @@ -212,7 +212,7 @@ public actor TransferClient { } public func cancel(transfer: RequestID) async throws -> CancelResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request(.cancel(transfer: transfer), opcode: .cancel) @@ -221,7 +221,7 @@ public actor TransferClient { } public func arm(packageObjectID: ObjectID, expectedRevision: Revision) async throws -> ArmResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request( @@ -232,7 +232,7 @@ public actor TransferClient { /// Destructively initialize the card as a new empty flat store. No iOS surface calls it today. public func format(expectedStoreID: StoreID, replacementStoreID: StoreID) async throws -> FormatResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request( @@ -247,7 +247,7 @@ public actor TransferClient { storeID: StoreID, objectID: ObjectID, revision: Revision, payloadLength: UInt64, payloadCRC32: UInt32 ) async throws -> ArchiveRideResult { - await acquire() + try await acquire() defer { release() } try Task.checkCancellation() let receipt = ControlRequest.archiveRide( @@ -413,6 +413,7 @@ public actor TransferClient { } private func request(_ request: ControlRequest, opcode: Opcode) async throws -> ControlResponse { + try Task.checkCancellation() let requestID = try makeRequestID() try await link.sendControlRecord(try request.frame(requestID: requestID).encode()) return try ControlResponse( @@ -495,9 +496,12 @@ public actor TransferClient { return id } - private func acquire() async { + private func acquire() async throws { + try Task.checkCancellation() if !busy { busy = true; return } await withCheckedContinuation { operationWaiters.append($0) } + do { try Task.checkCancellation() } + catch { release(); throw error } } private func release() { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift index 89f821fe4..31a202c36 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift @@ -20,12 +20,19 @@ import OBCProtocolV4 /// CoreBluetooth delivers every delegate callback on that queue. public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable { private let queue = DispatchQueue(label: "com.openbikecomputer.ble") - private lazy var central = CBCentralManager( - delegate: self, - queue: queue - ) + private var centralStorage: CBCentralManager? + private var central: CBCentralManager { + if let centralStorage { return centralStorage } + let manager = CBCentralManager(delegate: self, queue: queue) + centralStorage = manager + return manager + } private let discoveryStore: any BLEDiscoveryStore private var discoveryPolicy = BLEDiscoveryIntentPolicy() + private var pairingScan: AsyncPromise>? + private var pairingCandidates: [UUID: PairingDevice] = [:] + private var pairingSettle: DispatchWorkItem? + private var selectedPairingID: UUID? private let stateMulticast = AsyncMulticast(.disconnected) /// `nil` until the first real BAS value — the seed must not replay as "0%". @@ -60,9 +67,9 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // Outstanding operations, touched only on `queue`. Connecting is two phases: `discover()` // un-gated, then `authenticate()` gated, which raises the passkey sheet. private var discoveryAttempt: AsyncPromise>? - private var authenticateContinuation: CheckedContinuation? + private var authenticateAttempt: AsyncPromise>? - /// True only across the gated-phase retry beat, where `authenticateContinuation` is + /// True only across the gated-phase retry beat, where `authenticateAttempt` is /// momentarily nil. A disconnect in this window is terminal: kicking the reconnect loop /// could re-raise the passkey sheet. private var awaitingGatedRetry = false @@ -107,8 +114,6 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable init(discoveryStore: any BLEDiscoveryStore) { self.discoveryStore = discoveryStore super.init() - - _ = central // force manager creation (and a state callback) } // MARK: DeviceTransport — lifecycle @@ -140,7 +145,38 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable try await authenticate() } + public func scanForPairing() async throws -> [PairingDevice] { + let attempt = AsyncPromise>() + return try await withTaskCancellationHandler { + queue.async { [self] in + guard attempt.current == nil else { return } + guard pairingScan == nil, !discoveryPolicy.hasIntent else { + attempt.fulfill(.failure(DeviceError.notConnected)) + return + } + pairingCandidates = [:] + pairingScan = attempt + startConnectIfReady() + } + return try await attempt.value.get() + } onCancel: { + attempt.fulfill(.failure(CancellationError())) + self.queue.async { [self] in + guard pairingScan === attempt else { return } + finishPairingScan(.failure(CancellationError())) + } + } + } + + public func discover(_ candidate: PairingDevice) async throws { + try await discover(selecting: candidate.id) + } + public func discover() async throws { + try await discover(selecting: nil) + } + + private func discover(selecting candidateID: UUID?) async throws { // Phase 1: scan, connect, and discover the services and the un-gated characteristics // only. Resolves once every service's characteristics are in hand, so `deviceInfo()` can // read them, and never touches a gated characteristic. @@ -148,10 +184,15 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable try await withTaskCancellationHandler { queue.async { [self] in guard attempt.current == nil else { return } - guard discoveryAttempt == nil else { + guard discoveryAttempt == nil, pairingScan == nil else { attempt.fulfill(.failure(DeviceError.notConnected)) return } + if let candidateID, pairingCandidates[candidateID] == nil { + attempt.fulfill(.failure(DeviceError.deviceNotFound)) + return + } + selectedPairingID = candidateID discoveryAttempt = attempt discoveryPolicy.requestForeground() startConnectIfReady() @@ -195,17 +236,28 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // A terminal `DeviceError` already tore the intent down and is rethrown straight through. } - /// One gated-phase attempt: park the authenticate continuation and kick `beginAuthenticate()`. + /// One cancellable gated-phase attempt. Only its own cancellation can tear it down. /// Throws `GatedPairingWindowError` on a retryable failure, a `DeviceError` on a terminal one. private func runGatedPhaseOnce() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let attempt = AsyncPromise>() + try await withTaskCancellationHandler { queue.async { [self] in - // This attempt is now live; from here `authenticateContinuation` - // (not `awaitingGatedRetry`) owns drop handling. + guard attempt.current == nil else { return } + guard authenticateAttempt == nil else { + attempt.fulfill(.failure(DeviceError.notConnected)) + return + } awaitingGatedRetry = false - authenticateContinuation = cont + authenticateAttempt = attempt beginAuthenticate() } + try await attempt.value.get() + } onCancel: { + attempt.fulfill(.failure(CancellationError())) + self.queue.async { [self] in + guard authenticateAttempt === attempt else { return } + failAuthenticate(.notConnected) + } } } @@ -213,10 +265,11 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable await withCheckedContinuation { (cont: CheckedContinuation) in queue.async { [self] in let cancelForegroundConnection = discoveryPolicy.cancelForeground() - if cancelForegroundConnection, let peripheral { central.cancelPeripheralConnection(peripheral) } - if central.isScanning, !discoveryPolicy.hasIntent { central.stopScan() } + if pairingScan != nil { finishPairingScan(.failure(DeviceError.notConnected)) } + if cancelForegroundConnection, let peripheral { centralStorage?.cancelPeripheralConnection(peripheral) } + if centralStorage?.isScanning == true, !discoveryPolicy.hasIntent { centralStorage?.stopScan() } if discoveryAttempt != nil { failDiscover(.notConnected) } - if authenticateContinuation != nil { failAuthenticate(.notConnected) } + if authenticateAttempt != nil { failAuthenticate(.notConnected) } stateMulticast.send(.disconnected) if discoveryPolicy.hasIntent { startConnectIfReady() } cont.resume() @@ -315,19 +368,18 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable catch { throw deviceError(for: error) } let scope = LibraryScope( serial: try await readString(GATT.serialNumber), storeID: catalog.storeID.description) - var rides: [RideSummary] = [] - for entry in catalog.entries where !entry.flags.contains(.retained) - && !entry.flags.contains(.reserved) && !entry.flags.contains(.recording) { + let rides = catalog.entries.filter { + !$0.flags.contains(.retained) && !$0.flags.contains(.reserved) && !$0.flags.contains(.recording) + }.map { entry in let id = RideID( deviceObjectID: DeviceObjectID(entry.objectID.rawValue), scope: scope) - // The ride footer is not frozen yet, so the fielded decoder stays behind the GET path. + // LIST establishes content identity. Display fields come from the one verified GET + // when sync downloads a missing ride, never from a catalog preflight. let source = RideSource(storeID: catalog.storeID.description, objectID: entry.objectID.rawValue, revision: entry.revision.rawValue, payloadLength: entry.payloadLength, payloadCRC32: entry.payloadCRC32) - let downloaded = try await downloadRide(id: id, source: source) - var summary = try RideObjectCodec.decode(downloaded.payload, id: id).summary - summary.source = downloaded.source - rides.append(summary) + return RideSummary(id: id, name: entry.displayName, date: .distantPast, + distanceMeters: 0, source: source) } return RideCatalog(rides: rides, hiddenRideCount: 0) } @@ -667,15 +719,15 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // MARK: Connect flow (queue-confined) private func startConnectIfReady() { - guard discoveryPolicy.hasIntent else { return } + guard pairingScan != nil || discoveryPolicy.hasIntent else { return } guard discoveryPolicy.phase == .scanning || discoveryPolicy.phase == .idle else { return } switch central.state { case .poweredOn: - stateMulticast.send(.connecting) + if pairingScan == nil { stateMulticast.send(.connecting) } guard !central.isScanning else { return } // A known device is selected by its identifier in `discovered`. - let scanServices: [CBUUID]? = discoveryStore.knownPeripheralID() == nil - ? [GATT.obcControlService] : nil + let scanServices: [CBUUID]? = pairingScan != nil || selectedPairingID != nil + || discoveryStore.knownPeripheralID() == nil ? [GATT.obcControlService] : nil central.scanForPeripherals(withServices: scanServices) case .poweredOff: failRadioUnavailable(.bluetoothUnavailable(.poweredOff)) @@ -688,8 +740,17 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable } } - private func failRadioUnavailable(_ error: DeviceError) { + private func finishPairingScan(_ result: Result<[PairingDevice], Error>) { + pairingSettle?.cancel() + pairingSettle = nil + centralStorage?.stopScan() + pairingScan?.fulfill(result) + pairingScan = nil + if case .failure = result { pairingCandidates = [:] } + } + private func failRadioUnavailable(_ error: DeviceError) { + if pairingScan != nil { finishPairingScan(.failure(error)) } if discoveryAttempt != nil { failDiscover(error) } else { @@ -710,7 +771,7 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable } // Finish the protected subscription before opening the stream. A live CoC alone does // not prove the peer can deliver the result of any object operation. - armChannelWatchdog(after: authenticateContinuation != nil ? Self.pairingTimeout : Self.phaseTimeout) + armChannelWatchdog(after: authenticateAttempt != nil ? Self.pairingTimeout : Self.phaseTimeout) if control.isNotifying { openAuthenticatedChannel() } else { @@ -735,7 +796,7 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable let waiters = channelWaiters channelWaiters.removeAll() for waiter in waiters { waiter.resume(throwing: error) } - if authenticateContinuation != nil { + if authenticateAttempt != nil { failAuthenticate(error) } else if let peripheral { central.cancelPeripheralConnection(peripheral) @@ -803,8 +864,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable awaitingGatedRetry = false _ = discoveryPolicy.cancelForeground() stateMulticast.send(.disconnected) - authenticateContinuation?.resume(throwing: error) - authenticateContinuation = nil + authenticateAttempt?.fulfill(.failure(error)) + authenticateAttempt = nil if let peripheral { central.cancelPeripheralConnection(peripheral) } } @@ -815,8 +876,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable private func resolveAuthenticateRetryable() { disarmChannelWatchdog() awaitingGatedRetry = true - authenticateContinuation?.resume(throwing: GatedPairingWindowError()) - authenticateContinuation = nil + authenticateAttempt?.fulfill(.failure(GatedPairingWindowError())) + authenticateAttempt = nil } /// The single retry also failed with the pairing-window error, so the link may still be up @@ -884,8 +945,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable discoveryStore.saveKnownPeripheralID(peripheral.identifier) } awaitingGatedRetry = false - authenticateContinuation?.resume() - authenticateContinuation = nil + authenticateAttempt?.fulfill(.success(())) + authenticateAttempt = nil } @@ -1261,11 +1322,14 @@ extension BLETransport: TransferLink { do { _ = try ControlFrame(decoding: record, direction: .request) _ = try await readyChannel() + try Task.checkCancellation() queue.async { [self] in objectControlReceiveCancelled = false byteChannel?.expectControlResponse(true) } // a request re-arms the lane try await write(record, to: GATT.objectControl) + } catch is CancellationError { + throw CancellationError() } catch is WireError { throw DeviceError.writeFailed } catch { @@ -1366,16 +1430,36 @@ extension BLETransport: CBCentralManagerDelegate { if central.state != .poweredOn { clearPhysicalLink() discoveryPolicy.didDisconnect() - if authenticateContinuation != nil { failAuthenticate(.notConnected) } + if authenticateAttempt != nil { failAuthenticate(.notConnected) } } startConnectIfReady() } public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + if pairingScan != nil { + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? peripheral.name ?? DeviceInfo.unnamed + pairingCandidates[peripheral.identifier] = PairingDevice(id: peripheral.identifier, name: name) + if pairingSettle == nil { + // Collect a short window after the first advertisement so nearby devices can + // appear together. Choosing the first radio response can pair the wrong OBC. + let attempt = pairingScan + let settle = DispatchWorkItem { [weak self] in + guard let self, pairingScan === attempt else { return } + let candidates = pairingCandidates.values.sorted { + $0.name == $1.name ? $0.id.uuidString < $1.id.uuidString : $0.name < $1.name + } + finishPairingScan(.success(candidates)) + } + pairingSettle = settle + queue.asyncAfter(deadline: .now() + 2, execute: settle) + } + return + } let action = discoveryPolicy.discovered( peripheralID: peripheral.identifier, - knownPeripheralID: discoveryStore.knownPeripheralID() + knownPeripheralID: selectedPairingID ?? discoveryStore.knownPeripheralID() ) switch action { case .ignore: @@ -1410,7 +1494,7 @@ extension BLETransport: CBCentralManagerDelegate { clearPhysicalLink() if discoveryAttempt != nil { failDiscover(.notConnected) - } else if discoveryPolicy.foregroundRequested { + } else if discoveryPolicy.foregroundRequested || pairingScan != nil { startConnectIfReady() } } @@ -1428,7 +1512,7 @@ extension BLETransport: CBCentralManagerDelegate { failDiscover(.notConnected) return } - if authenticateContinuation != nil { + if authenticateAttempt != nil { failAuthenticate(.pairingFailed) return } @@ -1442,7 +1526,7 @@ extension BLETransport: CBCentralManagerDelegate { return } stateMulticast.send(discoveryPolicy.foregroundRequested ? .outOfRange : .disconnected) - if discoveryPolicy.hasIntent { + if discoveryPolicy.hasIntent || pairingScan != nil { startConnectIfReady() } } @@ -1530,7 +1614,7 @@ extension BLETransport: CBPeripheralDelegate { // waiters fail as before; the retry re-opens the CoC. if Self.isRetryableGatedFailure( error, peripheralConnected: peripheral.state == .connected, - authenticatePending: authenticateContinuation != nil + authenticatePending: authenticateAttempt != nil ) { for cont in waiters { cont.resume(throwing: DeviceError.channelOpenFailed) } resolveAuthenticateRetryable() @@ -1602,7 +1686,7 @@ extension BLETransport: CBPeripheralDelegate { openAuthenticatedChannel() } else if Self.isRetryableGatedFailure( error, peripheralConnected: peripheral.state == .connected, - authenticatePending: authenticateContinuation != nil + authenticatePending: authenticateAttempt != nil ) { resolveAuthenticateRetryable() } else { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift index 02ba143de..9de564c97 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift @@ -41,7 +41,7 @@ public enum RideObjectCodec { data.append(sensorU8(summary.avgHeartRate)) data.append(sensorU8(summary.maxHeartRate)) data.append(sensorU8(summary.avgCadence)) - data.append(0) + data.append(summary.isDemo ? 1 : 0) data.appendLE(sensorU16(summary.avgPower)) data.appendLE(sensorU16(summary.maxPower)) data.appendLE(summary.energyKJ.map { UInt32(clamping: Swift.min($0, Int(noEnergy) - 1)) } ?? noEnergy) @@ -85,7 +85,8 @@ public enum RideObjectCodec { let avgHR = optSensorU8(try footer.u8()) let maxHR = optSensorU8(try footer.u8()) let avgCadence = optSensorU8(try footer.u8()) - guard try footer.u8() == 0 else { throw DeviceError.readFailed } + let flags = try footer.u8() + guard flags & ~1 == 0 else { throw DeviceError.readFailed } let avgPower = optSensorU16(try footer.u16()) let maxPower = optSensorU16(try footer.u16()) let energy = try footer.u32() @@ -145,7 +146,7 @@ public enum RideObjectCodec { trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: avgHR, maxHeartRate: maxHR, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energy == noEnergy ? nil : Int(energy), - bikeType: bikeType, trip: trip, zoneLimits: zoneLimits) + bikeType: bikeType, trip: trip, zoneLimits: zoneLimits, isDemo: flags & 1 != 0) return Ride(summary: summary, points: points) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift new file mode 100644 index 000000000..b58f37cb6 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift @@ -0,0 +1,21 @@ +import Foundation +import OBCDomain + +/// An advertisement from one nearby OBC. The identifier selects the radio peer, never its name. +public struct PairingDevice: Identifiable, Equatable, Sendable { + public let id: UUID + public let name: String + + public init(id: UUID, name: String) { + self.id = id + self.name = name + } +} + +/// First-use discovery keeps the candidate scan separate from connecting and authentication. +public protocol DevicePairing: Sendable { + /// Collect nearby OBC advertisements before choosing. Cancellation stops the scan. + func scanForPairing() async throws -> [PairingDevice] + /// Connect only the selected candidate and discover its unprotected characteristics. + func discover(_ candidate: PairingDevice) async throws +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift new file mode 100644 index 000000000..2a7e9f5c6 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift @@ -0,0 +1,19 @@ +import Foundation +import OBCDomain + +public enum DeviceRenaming { + public static func normalized(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + .truncatedToUTF8Bytes(DeviceConfig.maxNameUTF8Bytes) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Rename through the existing config command, preserving every other setting. + public static func save(_ name: String, to transport: any DeviceConfiguration) async throws { + let name = normalized(name) + guard !name.isEmpty else { throw DeviceError.writeFailed } + var config = try await transport.readConfig() + config.name = name + try await transport.writeConfig(config) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift index de991877b..c07618692 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift @@ -18,7 +18,7 @@ public protocol DeviceLink: Sendable { func discover() async throws /// First-time-pairing phase 2: the gated operations that establish the encrypted, /// LESC-authenticated link. This is what raises the system passkey sheet, so the launch flow - /// calls it on the device-row tap. Requires a prior `discover()`. + /// calls it after selecting a nearby device. Requires a prior `discover()`. func authenticate() async throws /// Tear the link down. func disconnect() async @@ -133,7 +133,7 @@ public protocol DeviceUpdates: Sendable { func installFirmware() async throws -> FirmwareInstallResult } -public protocol DeviceTransport: DeviceLink, DeviceBattery, DeviceConfiguration, +public protocol DeviceTransport: DeviceLink, DevicePairing, DeviceBattery, DeviceConfiguration, DeviceBonding, DeviceObjects, DeviceClock, DeviceUpdates { // MARK: Control plane func readDiagnostics() async throws -> Data diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift index 2baa7efab..e41542260 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift @@ -943,6 +943,7 @@ private struct RidePointDTO: Codable { } private struct RideSummaryDTO: Codable { + var isDemo: Bool? var source: RideSource? var id: String var name: String @@ -969,6 +970,7 @@ private struct RideSummaryDTO: Codable { init(_ summary: RideSummary) { source = summary.source + isDemo = summary.isDemo id = summary.id.rawValue name = summary.name date = summary.date @@ -998,7 +1000,7 @@ private struct RideSummaryDTO: Codable { avgHeartRate: avgHeartRate, maxHeartRate: maxHeartRate, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energyKJ, bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, - zoneLimits: zoneLimits ?? .notSet, source: source + zoneLimits: zoneLimits ?? .notSet, source: source, isDemo: isDemo ?? false ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift index 1d319e5ff..7bade3607 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift @@ -118,6 +118,7 @@ extension LibraryStore { /// bike type. @discardableResult public func mergeRides(_ first: RideSummary, _ second: RideID) -> Bool { + guard rideSummaries().first(where: { $0.id == second })?.isDemo == first.isDemo else { return false } var edit = RideEditSession(store: self) guard let head = edit.slices(of: first.id), let tail = edit.slices(of: second), let view = edit.view(id: first.id, summary: first, slices: head + tail) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift index 2ebdea0cb..6ce597e5d 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift @@ -7,7 +7,8 @@ import SwiftUI /// The screen draws a device page in its exact on-glass colours and Terminus. struct DeviceGlyphView: View { enum Variant { - /// The title bar with the device's name and the amber track squiggle. + case welcome + /// The title bar with the device's name and the signpost mark. case home(name: String) /// The device's pairing card. The app never knows the code, so the digits are blanks. case passkey @@ -89,6 +90,8 @@ struct DeviceGlyphView: View { @ViewBuilder private var screenContent: some View { switch variant { + case .welcome: + DeviceWelcomeScreen() case .home(let name): DeviceHomeScreen(name: name) case .passkey: @@ -104,6 +107,7 @@ struct DeviceGlyphView: View { private var accessibilityText: String { switch variant { + case .welcome: "The bike computer's welcome screen" case .home(let name): "\(name), the bike computer" case .passkey: "The bike computer's pairing screen, which shows a six-digit code" case .routeOverview(let overview): "The bike computer's screen showing \(overview.name)" @@ -125,7 +129,7 @@ struct DeviceGlyphView: View { } } -/// The name in the title bar over the amber track squiggle. +/// The name in the title bar over the signpost mark. private struct DeviceHomeScreen: View { let name: String @@ -133,16 +137,21 @@ private struct DeviceHomeScreen: View { Canvas { context, size in let title = name.count <= 15 ? name : String(name.prefix(13)).trimmingCharacters(in: .whitespaces) + ".." context.deviceFrame(size: size, title: title) - // The source path is "M12 60 C 20 40 34 44 40 52 C 48 62 60 40 68 20" in an 80×74 box. - let box = CGRect(x: 24, y: 70, width: 192, height: 200) - func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { - CGPoint(x: box.minX + x / 80 * box.width, y: box.minY + y / 74 * box.height) + context.draw(Image("Signpost", bundle: .module), + in: CGRect(x: 40, y: 96, width: 160, height: 160)) + } + .environment(\.colorScheme, .light) + } +} + +private struct DeviceWelcomeScreen: View { + var body: some View { + Canvas { context, size in + context.deviceFrame(size: size, title: "WELCOME") + for (index, greeting) in ["Hello", "Hallo", "Bonjour", "Hola"].enumerated() { + context.pixelText(greeting, .display, x: 120, capTop: CGFloat(70 + index * 44), + color: OBCTheme.deviceInk, centered: true) } - var path = Path() - path.move(to: point(12, 60)) - path.addCurve(to: point(40, 52), control1: point(20, 40), control2: point(34, 44)) - path.addCurve(to: point(68, 20), control1: point(48, 62), control2: point(60, 40)) - context.stroke(path, with: .color(OBCTheme.deviceTrack), style: StrokeStyle(lineWidth: 6, lineCap: .round)) } } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift index 01dc69bc5..d70c77daa 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift @@ -7,6 +7,7 @@ import OBCDomain /// sketch carries its day count in the corner, as the device's route menu badges a trip. At accessibility text sizes the sketch moves above the text. public struct TrackRow: View { let name: String + let isDemo: Bool let stats: Text /// The stat line as VoiceOver reads it, with the on-device state. let statsLabel: String @@ -49,7 +50,7 @@ public struct TrackRow: View { self.init( name: ride.name, stats: stats, detail: nil, sketch: TrackPreviewView(ride.trackPreview, ink: .ride, showsChrome: false), - onDevice: .notOnDevice, dayCount: nil, isSelected: nil + onDevice: .notOnDevice, dayCount: nil, isSelected: nil, isDemo: ride.isDemo ) } @@ -79,11 +80,12 @@ public struct TrackRow: View { private init( name: String, stats: RowStats, detail: String?, sketch: TrackPreviewView, - onDevice: OnDeviceState, dayCount: Int?, isSelected: Bool? + onDevice: OnDeviceState, dayCount: Int?, isSelected: Bool?, isDemo: Bool = false ) { self.name = name + self.isDemo = isDemo self.stats = stats.text - self.statsLabel = [stats.label, OnDeviceChip.label(for: onDevice)] + self.statsLabel = [stats.label, OnDeviceChip.label(for: onDevice), isDemo ? "Demo ride, excluded from totals" : nil] .compactMap { $0 } .joined(separator: ", ") self.detail = detail @@ -149,10 +151,13 @@ public struct TrackRow: View { .font(.system(.subheadline).monospacedDigit()) .foregroundStyle(OBCTheme.secondary) .accessibilityLabel(statsLabel) - if onDevice == .notOnDevice { + if onDevice == .notOnDevice && !isDemo { styled } else { - let chip = OnDeviceChip(upToDate: onDevice == .upToDate).accessibilityHidden(true) + let chip = Group { + if isDemo { DemoRideBadge() } + else { OnDeviceChip(upToDate: onDevice == .upToDate) } + }.accessibilityHidden(true) ViewThatFits(in: .horizontal) { HStack(spacing: 8) { styled.fixedSize() @@ -319,3 +324,14 @@ public struct TrackRowSkeleton: View { .background(OBCTheme.page) } #endif + +struct DemoRideBadge: View { + var body: some View { + PixelText("DEMO", scale: 2 / 3, color: OBCTheme.secondary) + .padding(.horizontal, 4) + .frame(height: OnDeviceChip.height) + .background(OBCTheme.fill, in: RoundedRectangle(cornerRadius: 4)) + .accessibilityLabel("Demo ride, excluded from totals") + .accessibilityIdentifier("ride.demoBadge") + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift index 85412c78f..5331748e7 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift @@ -291,6 +291,9 @@ public struct RouteDetailView: View { .accessibilityIdentifier("detail.rename") } } + if case let .tracked(ride) = model.dressing, ride.isDemo { + DemoRideBadge() + } if let subtitle = model.subtitle { Text(subtitle) .font(.system(.subheadline)) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift index 10ccac480..9eeb4f7c1 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift @@ -46,8 +46,9 @@ public final class FirmwareUpdateModel { } } public private(set) var progress = TransferProgress(bytesDone: 0, total: 0) - /// The device's running firmware version, nil until it lands or while the link is down. + /// The last version read from the device. Kept offline so a known update can be downloaded. public private(set) var runningVersion: String? + public private(set) var deviceSerial: String? public private(set) var staged: StagedFirmware? public private(set) var connection: ConnectionState = .connecting /// A picked file that is not a usable update, surfaced as an alert and never a phase. @@ -97,6 +98,7 @@ public final class FirmwareUpdateModel { @ObservationIgnored private let updateChecker: UpdateChecker? @ObservationIgnored private var handle: TransferHandle? @ObservationIgnored private var stateTask: Task? + @ObservationIgnored private var versionTask: Task? @ObservationIgnored private var checkTask: Task? @ObservationIgnored private var downloadTask: Task? @ObservationIgnored private var transferWatchers: [Task] = [] @@ -238,20 +240,17 @@ public final class FirmwareUpdateModel { } stateTask = Task { [weak self, transport] in for await state in transport.state { - guard let self else { return } + guard let self, !Task.isCancelled else { return } handleState(state) } } - Task { [weak self, transport] in - guard let info = try? await transport.deviceInfo() else { return } - guard let self else { return } - runningVersion = info.firmwareVersion - } checkForUpdate() } private func handleState(_ state: ConnectionState) { connection = state + versionTask?.cancel() + versionTask = nil let dropped = !linkUp switch phase { case .transferring where dropped: @@ -259,20 +258,21 @@ public final class FirmwareUpdateModel { if let handle, handle.currentOutcome == nil { phase = .interrupted } case .awaitingConfirm: if dropped { sawDropSinceInstall = true } - if state == .connected, sawDropSinceInstall { checkInstalledVersion() } default: break } + if state == .connected { refreshRunningVersion() } } - /// A reconnect after the install reboot: re-read the device info. The staged version now - /// running means the update landed. - private func checkInstalledVersion() { - Task { [weak self, transport] in + /// Every connection gets a fresh version. After an install reboot it also confirms success. + private func refreshRunningVersion() { + versionTask = Task { [weak self, transport] in guard let info = try? await transport.deviceInfo() else { return } - guard let self else { return } + guard let self, !Task.isCancelled, connection == .connected else { return } runningVersion = info.firmwareVersion - if let staged, info.firmwareVersion == staged.version, phase == .awaitingConfirm { + deviceSerial = info.serial + if let staged, info.firmwareVersion == staged.version, + phase == .awaitingConfirm, sawDropSinceInstall { phase = .done } } @@ -480,6 +480,8 @@ public final class FirmwareUpdateModel { started = false stateTask?.cancel() stateTask = nil + versionTask?.cancel() + versionTask = nil // The check and the download are screen-scoped too: a popped screen has nobody to tell, // and `start()` re-runs the check from the cache anyway. A killed download stages nothing. checkTask?.cancel() @@ -504,6 +506,7 @@ public final class FirmwareUpdateModel { deinit { stateTask?.cancel() + versionTask?.cancel() checkTask?.cancel() downloadTask?.cancel() transferWatchers.forEach { $0.cancel() } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift index 7d2d596fd..26a869adc 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift @@ -28,7 +28,9 @@ public final class UpdateSurfaceModel { @ObservationIgnored private let notifier: (any UpdateNotifying)? @ObservationIgnored private var stateTask: Task? @ObservationIgnored private var checkTask: Task? + @ObservationIgnored private var notificationTask: Task? @ObservationIgnored private var started = false + @ObservationIgnored private var enabled = true /// The device the offer is about, so a dismiss writes the ledger under the right key. @ObservationIgnored private var pendingDevice: LastSeenDevice? @@ -51,7 +53,7 @@ public final class UpdateSurfaceModel { stateTask = Task { [weak self, transport] in for await state in transport.state { guard let self else { return } - guard state == .connected else { continue } + guard enabled, state == .connected else { continue } await rememberDevice() // The link arrives after the app does, so the check on `.active` had // only the persisted version. Re-decide with the live one; the cache @@ -61,20 +63,33 @@ public final class UpdateSurfaceModel { } } + /// Setup owns the update offer while it is open, including when the rider replays it. + public func setEnabled(_ enabled: Bool) { + self.enabled = enabled + guard !enabled else { return } + checkTask?.cancel() + checkTask = nil + notificationTask?.cancel() + notificationTask = nil + pending = nil + pendingDevice = nil + } + /// The app came to the front. Runs the policy; presents a sheet only if it says to. public func appBecameActive() { - guard let runner, pending == nil, checkTask == nil else { return } + guard enabled, let runner, pending == nil, checkTask == nil else { return } checkTask = Task { [weak self, runner] in // A live read beats the remembered one and refreshes it. `?? nil` flattens // the optional chain: no model and no link both land on the persisted record. let live = await self?.rememberDevice() ?? nil + guard !Task.isCancelled, self?.enabled == true else { return } // Capture the fallback once: another device can connect while the request // is in flight, and the offer must stay with the evaluated device. let target = runner.device(live) let release = await runner.run(device: target) - guard let self else { return } + guard let self, !Task.isCancelled, enabled else { return } checkTask = nil - guard !Task.isCancelled, pending == nil, let release else { return } + guard pending == nil, let release else { return } present(release, device: target) } } @@ -101,8 +116,11 @@ public final class UpdateSurfaceModel { private func askForNotificationPermissionOnce() { guard let runner, let notifier, !runner.didAskNotificationPermission else { return } - runner.markAskedNotificationPermission() - Task { await notifier.requestAuthorization() } + notificationTask = Task { [weak self] in + guard let self, !Task.isCancelled, enabled else { return } + runner.markAskedNotificationPermission() + await notifier.requestAuthorization() + } } /// "View": acting on the offer is also an answer. Navigation belongs to the host. @@ -126,5 +144,6 @@ public final class UpdateSurfaceModel { deinit { stateTask?.cancel() checkTask?.cancel() + notificationTask?.cancel() } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift index 3acef3d46..608836e73 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift @@ -229,18 +229,18 @@ public struct OBCComponentGallery: View { #endif section("Launch & Pairing") { - launchScreen { LaunchConnectingView(deviceName: "Trailhead") } - launchScreen { LaunchConnectFailedView(deviceName: "Trailhead", onRetry: {}, onGoToRoutes: {}) } - launchScreen { PairIntroView(onStart: {}) } + launchScreen { WelcomeView(onStart: {}, onBrowse: {}) } + launchScreen { SwitchOnView(onFind: {}, onBack: {}) } + launchScreen { BluetoothPermissionView(onAllow: {}, onBack: {}, onBrowse: {}) } launchScreen { PairScanningView( - discovered: .init(name: "Trailhead"), - onTapDevice: {}, + devices: [], + onTapDevice: { _ in }, onCancel: {} ) } launchScreen { PairingBackdropView() } - launchScreen { PairedView(deviceName: "Trailhead", onContinue: {}) } + launchScreen { PairedView(deviceName: "Trailhead", name: .constant("Trailhead"), onSave: {}, onKeepName: {}) } launchScreen { PairFailedView(failure: .timeout, onRetry: {}, onHelp: {}) } launchScreen { PairFailedView(failure: .rejected, onRetry: {}, onHelp: {}) } launchScreen { RadioBlockedView(block: .off, onRetry: {}, onBrowseLibrary: {}) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift index 77b1a038c..9fdbc6fd7 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift @@ -3,27 +3,13 @@ import Observation import OBCDomain import OBCTransport -/// The launch branch and the first-run pairing flow, as one `@Observable` state machine the -/// host view renders; `Phase` below lists the screens. -/// -/// Depends only on `DeviceLink` and `BondStore`. Pairing is a two-phase connect: `startPairing` -/// runs the un-gated `discover()`, which surfaces the device row, and the row tap runs the gated -/// `authenticate()`, the op that raises the system passkey sheet. The sheet therefore lands in -/// the pairing beat, not on the scanning screen. "Have we bonded before" comes from the -/// `BondStore`, never from a CoreBluetooth detour. +/// Launch, nearby-device selection and pairing. Optional setup owns its own progress after `.setup`. @MainActor @Observable public final class LaunchFlowModel { - /// Why pairing failed; it selects the copy variant. public enum PairingFailure: Equatable, Sendable { - /// Scan ended without finding the device (`DeviceError.deviceNotFound`). case timeout - /// Found it, but pairing did not complete. Covers a declined or wrong passkey and a - /// device that refuses because it is already bonded to another phone: the device - /// suppresses its passkey and drops the link, and no distinguishable reason reaches the - /// app, so this one case carries the combined copy. case rejected - /// The headline for this failure. On the model, so the copy is testable without a view. public var title: String { switch self { case .timeout: "Couldn't find your OBC" @@ -31,9 +17,6 @@ public final class LaunchFlowModel { } } - /// The body copy. For `.rejected` it is deliberately combined: a declined passkey and an - /// already-bonded refusal arrive identically over the wire, so it offers both recoveries - /// without claiming which one happened. public var reason: String { switch self { case .timeout: @@ -44,298 +27,268 @@ public final class LaunchFlowModel { } } - /// Why the radio is unusable. - public enum RadioBlock: Equatable, Sendable { - case off - case denied - } - - /// The device row that slides into the scanning screen. - public struct DiscoveredDevice: Equatable, Sendable { - /// The clean device name, which the greeting shows and the bond record stores. - public var name: String + public enum RadioBlock: Equatable, Sendable { case off, denied } - public init(name: String) { - self.name = name - } - - /// What the device advertises. - public var advertisedName: String { - name.hasPrefix("OBC-") ? name : "OBC-\(name)" - } - } - - /// The screen being shown. The host view switches over this exhaustively. public enum Phase: Equatable, Sendable { - /// Pre-`start()` blank; it reads as the launch screen. case idle - /// Bonded and quietly reconnecting. Resolves to `.main`, or to `.connectFailed` when the - /// grace expires with the device silent. - case connecting(deviceName: String) - /// The bonded device did not answer within the grace window. The background attempt - /// keeps listening either way. - case connectFailed(deviceName: String) + case welcome case pairIntro - /// Scanning; the row slides in when `discovered` is non-nil. - case scanning(discovered: DiscoveredDevice?) - /// The beat while the system pairing completes. + case bluetoothPermission + case scanning(devices: [PairingDevice]) case pairing case paired(deviceName: String) case pairFailed(PairingFailure) - /// The radio is off, or Bluetooth access was denied. case radioBlocked(RadioBlock) - /// Hand over to the main screen. + case setup case main } - /// Flow pacing, injectable so the model tests run in milliseconds. public struct Timing: Sendable { - /// How long the connecting state may hold before it resolves: never a blocking - /// full-screen spinner. The connect attempt keeps trying in the background either way. - public var connectGrace: Duration - /// The scan window; expiry drives the "we scanned for 30 seconds" copy. public var scanTimeout: Duration - /// The minimum dwell after the gated `authenticate()` resolves, so the pairing beat is - /// perceptible even when the mock authenticates instantly. public var pairingBeat: Duration public init( - connectGrace: Duration = .seconds(8), scanTimeout: Duration = .seconds(30), pairingBeat: Duration = .milliseconds(700) ) { - self.connectGrace = connectGrace self.scanTimeout = scanTimeout self.pairingBeat = pairingBeat } } public private(set) var phase: Phase = .idle + public var deviceNameDraft = "" + public private(set) var savingName = false + public private(set) var nameSaveError: String? + public var canSaveName: Bool { !savingName && !DeviceRenaming.normalized(deviceNameDraft).isEmpty } - private let transport: any DeviceLink + private let transport: any DeviceLink & DevicePairing & DeviceConfiguration private let bondStore: any BondStore private let timing: Timing + private let onboardingPending: @MainActor () -> Bool @ObservationIgnored private var flowTask: Task? - /// The one background bonded-connect attempt (see `startConnectAttemptIfNeeded`). @ObservationIgnored private var connectAttempt: Task? + @ObservationIgnored private var teardownTask: Task? public init( - transport: any DeviceLink, - bondStore: any BondStore, - timing: Timing = Timing() + transport: any DeviceLink & DevicePairing & DeviceConfiguration, + bondStore: any BondStore, timing: Timing = Timing(), + onboardingPending: @escaping @MainActor () -> Bool = { false } ) { self.transport = transport self.bondStore = bondStore self.timing = timing + self.onboardingPending = onboardingPending } deinit { flowTask?.cancel() connectAttempt?.cancel() + teardownTask?.cancel() } - // MARK: The launch branch - - /// Check the bond and branch. Call once. public func start() { guard phase == .idle else { return } - if let bond = bondStore.load() { - beginBondedConnect(bond) - } else { - phase = .pairIntro - } - } - - private func beginBondedConnect(_ bond: BondRecord) { - phase = .connecting(deviceName: bond.deviceName) - flowTask = Task { [transport, timing] in - // The state stream replays its latest value: already connected, or degraded but - // known, means there is nothing to wait for, and out of range means the transport's - // own reconnect loop is already on it. - var current: ConnectionState? - for await state in transport.state { current = state; break } - if current == .connected || current == .outOfRange { - phase = .main + guard bondStore.load() != nil else { phase = .welcome; return } + phase = onboardingPending() ? .setup : .main + // The Library observes link status. Reconnection never owns navigation. + connectAttempt = Task { [transport] in + for await state in transport.state { + guard !Task.isCancelled, state == .disconnected else { return } + try? await transport.connect() return } - startConnectAttemptIfNeeded() - // Watch for the link under the grace cap. The connect attempt runs unstructured, - // because the real `connect()` is not cancellation-responsive while it scans for an - // absent device: racing it inside a task group wedged the group's implicit drain. - let connected = await Self.linkCameUp(transport.state, within: timing.connectGrace) - guard !Task.isCancelled else { return } - phase = connected ? .main : .connectFailed(deviceName: bond.deviceName) } } - /// The background bonded-connect attempt, started at most once. While the device is out of - /// reach the transport keeps scanning, so "Try again" re-watches this same attempt under a - /// fresh grace window instead of stacking scans. When it resolves it finishes a still-waiting - /// screen: a hard failure degrades to main, because the library never locks. - private func startConnectAttemptIfNeeded() { - guard connectAttempt == nil else { return } - connectAttempt = Task { [transport, weak self] in - try? await transport.connect() - guard let self, !Task.isCancelled else { return } - connectAttempt = nil - switch phase { - case .connecting, .connectFailed: - flowTask?.cancel() - phase = .main - default: - break - } - } + public static func acceptsPairingLink(_ url: URL) -> Bool { + guard let link = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return link.scheme?.lowercased() == "https" && link.host?.lowercased() == "openbikecomputer.com" + && link.percentEncodedPath == "/app" && link.query == nil && link.fragment == nil + && link.user == nil && link.password == nil && link.port == nil } - /// Whether `states` reports `.connected` within `grace`. Both children are - /// cancellation-responsive, so the group's implicit drain cannot wedge. - private static func linkCameUp( - _ states: AsyncStream, - within grace: Duration - ) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { - for await state in states where state == .connected { return true } - return false - } - group.addTask { - try? await Task.sleep(for: grace) - return false - } - let first = await group.next() ?? false - group.cancelAll() - return first + /// The fixed QR link selects the pairing door, never a different device or an existing bond. + public func openPairingLink() { + switch phase { + case .scanning, .pairing, .paired, .setup: return + default: break + } + if bondStore.load() != nil { + if phase == .idle { start() } + } else { + startPairing() } } - /// "Try again": back to connecting under a fresh grace window. A bond that vanished - /// underneath, from a raced forget, falls back to the pairing prompt. - public func retryConnect() { - guard let bond = bondStore.load() else { - phase = .pairIntro - return - } - flowTask?.cancel() - beginBondedConnect(bond) + public func showSwitchOn() { + cancelPairingWork() + phase = .pairIntro } - // MARK: The pairing flow + public func showWelcome() { + cancelPairingWork() + phase = .welcome + } - /// Scan and discover the un-gated surface under the scan window, then show the found device - /// as a row. This is `discover()`, not `connect()`: touching a gated characteristic raises - /// the passkey sheet, and that is deferred to the row tap. + /// Present the explanation before anything can instantiate the Bluetooth manager. public func startPairing() { - flowTask?.cancel() - phase = .scanning(discovered: nil) + guard bondStore.load() == nil else { return } + cancelPairingWork() + phase = .bluetoothPermission + } + + public func allowBluetooth() { + guard phase == .bluetoothPermission || isRetryScreen else { return } + cancelPairingWork() + let teardown = teardownTask + phase = .scanning(devices: []) flowTask = Task { [transport, timing] in + await teardown?.value + guard !Task.isCancelled else { return } do { - try await Self.withScanWindow(timing.scanTimeout) { - try await transport.discover() + let devices = try await Self.withScanWindow(timing.scanTimeout) { + try await transport.scanForPairing() } - // The device exists; let the row slide in and wait for the rider's tap. - let name = (try? await transport.deviceInfo().name) ?? DeviceInfo.unnamed guard !Task.isCancelled else { return } - phase = .scanning(discovered: DiscoveredDevice(name: name)) + guard !devices.isEmpty else { throw DeviceError.deviceNotFound } + if devices.count == 1 { + await pair(devices[0]) + } else { + phase = .scanning(devices: devices) + } } catch { + guard !Task.isCancelled else { return } + await transport.disconnect() guard !Task.isCancelled else { return } phase = Self.failurePhase(for: error) } } } - /// The row tap: run the gated `authenticate()`, the operation that raises the system passkey - /// sheet, inside the pairing beat that is already on screen. On success record the bond and - /// celebrate; a decline drops to the failure screen. - public func confirmPairing() { - guard case .scanning(.some(let device)) = phase else { return } + public func confirmPairing(_ device: PairingDevice) { + guard case .scanning(let devices) = phase, devices.contains(device) else { return } + phase = .pairing + flowTask = Task { await pair(device) } + } + + private func pair(_ device: PairingDevice) async { phase = .pairing - flowTask = Task { [transport, bondStore, timing] in + do { + try await Self.withScanWindow(timing.scanTimeout) { [transport] in + try await transport.discover(device) + } + try Task.checkCancellation() + let name = (try? await transport.deviceInfo())?.name ?? device.name + try await Task.sleep(for: timing.pairingBeat) + try await transport.authenticate() + try Task.checkCancellation() + // Store the bond and advance together: Cancel cannot land in a success animation + // after pairing has already succeeded. + bondStore.save(BondRecord(deviceName: name)) + deviceNameDraft = name + nameSaveError = nil + phase = .paired(deviceName: name) + } catch { + guard !Task.isCancelled else { return } + await transport.disconnect() + guard !Task.isCancelled else { return } + phase = Self.failurePhase(for: error) + } + } + + /// The name is committed on the OBC before the phone records it or advances. + public func saveNameAndContinue() { + guard case .paired(let current) = phase, canSaveName else { return } + let name = DeviceRenaming.normalized(deviceNameDraft) + guard name != current else { finishPairing(); return } + savingName = true + nameSaveError = nil + flowTask = Task { [transport, bondStore] in do { - try await transport.authenticate() + try await DeviceRenaming.save(name, to: transport) + guard !Task.isCancelled else { return } + bondStore.save(BondRecord(deviceName: name)) + deviceNameDraft = name + savingName = false + phase = .setup } catch { guard !Task.isCancelled else { return } - phase = Self.failurePhase(for: error) - return + savingName = false + nameSaveError = "The name did not save. Keep your OBC nearby and try again, or keep its current name." } - // A minimum dwell, so success does not snap straight to the greeting. - try? await Task.sleep(for: timing.pairingBeat) - guard !Task.isCancelled else { return } - bondStore.save(BondRecord(deviceName: device.name)) - phase = .paired(deviceName: device.name) } } public func finishPairing() { - phase = .main + guard case .paired = phase, !savingName else { return } + phase = .setup } - public func retryPairing() { - startPairing() - } + public func finishSetup() { phase = .main } - public func showPairingHelp() { - flowTask?.cancel() - phase = .pairIntro + public func replaySetup() { + cancelPairingWork() + phase = bondStore.load() == nil ? .welcome : .setup } - /// Stop the scan, or drop a half-open link, and step back. - public func cancelScanning() { - flowTask?.cancel() - flowTask = Task { [transport] in - await transport.disconnect() - } - phase = .pairIntro - } + public func retryPairing() { allowBluetooth() } + public func showPairingHelp() { showSwitchOn() } + public func cancelScanning() { showSwitchOn() } - /// The library never locks. A still-running connect attempt keeps listening, so the link - /// comes up on its own once the device is nearby. public func browseLibrary() { - flowTask?.cancel() + cancelPairingWork() phase = .main } - /// The bond record is already cleared and the link dropped by the Settings flow; cancel - /// anything in flight and return to the pairing prompt. public func forgetDevice() { - flowTask?.cancel() - connectAttempt?.cancel() // its completion must not touch the phase now + cancelPairingWork() + connectAttempt?.cancel() connectAttempt = nil - phase = .pairIntro + phase = .welcome + } + + private var isRetryScreen: Bool { + switch phase { + case .pairFailed, .radioBlocked: true + default: false + } } - // MARK: Helpers + private func cancelPairingWork() { + flowTask?.cancel() + flowTask = nil + switch phase { + case .scanning, .pairing: + let previous = teardownTask + teardownTask = Task { [transport] in + await previous?.value + await transport.disconnect() + } + default: break + } + } - /// Run `connect` under the scan window; expiry throws `deviceNotFound`. - private static func withScanWindow( - _ window: Duration, - _ connect: @escaping @Sendable () async throws -> Void - ) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await connect() } + private static func withScanWindow( + _ window: Duration, _ operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } group.addTask { try await Task.sleep(for: window) throw DeviceError.deviceNotFound } - // First child to finish decides; the loser's error is discarded with the group. - try await group.next() - group.cancelAll() + defer { group.cancelAll() } + return try await group.next()! } } private static func failurePhase(for error: Error) -> Phase { switch error { - case DeviceError.bluetoothUnavailable(.unauthorized): - return .radioBlocked(.denied) - case DeviceError.bluetoothUnavailable: - return .radioBlocked(.off) - case DeviceError.deviceNotFound: - return .pairFailed(.timeout) - case DeviceError.pairingFailed: - // Declined or wrong passkey, or the encrypted link was refused. - return .pairFailed(.rejected) - default: - return .pairFailed(.rejected) + case DeviceError.bluetoothUnavailable(.unauthorized): .radioBlocked(.denied) + case DeviceError.bluetoothUnavailable: .radioBlocked(.off) + case DeviceError.deviceNotFound: .pairFailed(.timeout) + default: .pairFailed(.rejected) } } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift index 7e68adcb0..a8ab20732 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift @@ -1,29 +1,24 @@ import SwiftUI -import OBCDomain -import OBCTransport -/// The launch gate: renders whatever `LaunchFlowModel` says, from the connecting -/// state through the pairing flow, and hands over to `main` once the flow lands. -/// Own the model at the composition root and pass the main screen as the trailing -/// closure. -public struct LaunchFlowView: View { - private let model: LaunchFlowModel +public struct LaunchFlowView: View { + @Bindable private var model: LaunchFlowModel private let main: Main + private let setup: Setup + @Environment(\.accessibilityReduceMotion) private var reduceMotion - public init(model: LaunchFlowModel, @ViewBuilder main: () -> Main) { + public init( + model: LaunchFlowModel, @ViewBuilder setup: () -> Setup, @ViewBuilder main: () -> Main + ) { self.model = model self.main = main() + self.setup = setup() } public var body: some View { ZStack { - // ZStack and a transition, rather than a bare switch, so phase changes - // cross-fade instead of hard-cutting. - screen - .id(phaseKey) - .transition(.opacity) + screen.id(phaseKey).transition(.opacity) } - .animation(.easeInOut(duration: 0.25), value: phaseKey) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: phaseKey) .task { model.start() } } @@ -32,57 +27,51 @@ public struct LaunchFlowView: View { switch model.phase { case .idle: OBCTheme.page.ignoresSafeArea() - case .connecting(let deviceName): - LaunchConnectingView(deviceName: deviceName) - case .connectFailed(let deviceName): - LaunchConnectFailedView( - deviceName: deviceName, - onRetry: { model.retryConnect() }, - onGoToRoutes: { model.browseLibrary() } - ) + case .welcome: + WelcomeView(onStart: model.showSwitchOn, onBrowse: model.browseLibrary) case .pairIntro: - PairIntroView(onStart: { model.startPairing() }) - case .scanning(let discovered): - PairScanningView( - discovered: discovered, - onTapDevice: { model.confirmPairing() }, - onCancel: { model.cancelScanning() } - ) + SwitchOnView(onFind: model.startPairing, onBack: model.showWelcome) + case .bluetoothPermission: + BluetoothPermissionView( + onAllow: model.allowBluetooth, onBack: model.showSwitchOn, onBrowse: model.browseLibrary) + case .scanning(let devices): + PairScanningView(devices: devices, onTapDevice: model.confirmPairing, onCancel: model.cancelScanning) case .pairing: - PairingBackdropView() + PairingBackdropView(onCancel: model.cancelScanning) case .paired(let deviceName): - PairedView(deviceName: deviceName, onContinue: { model.finishPairing() }) + PairedView( + deviceName: deviceName, name: $model.deviceNameDraft, saving: model.savingName, + error: model.nameSaveError, onSave: model.saveNameAndContinue, onKeepName: model.finishPairing) case .pairFailed(let failure): - PairFailedView( - failure: failure, - onRetry: { model.retryPairing() }, - onHelp: { model.showPairingHelp() } - ) + PairFailedView(failure: failure, onRetry: model.retryPairing, onHelp: model.showPairingHelp) case .radioBlocked(let block): - RadioBlockedView( - block: block, - onRetry: { model.retryPairing() }, - onBrowseLibrary: { model.browseLibrary() } - ) + RadioBlockedView(block: block, onRetry: model.retryPairing, onBrowseLibrary: model.browseLibrary) + case .setup: + setup case .main: main } } - /// A stable per-screen key: the scanning row appearing must animate inside the - /// screen, not re-create it. private var phaseKey: String { switch model.phase { case .idle: "idle" - case .connecting: "connecting" - case .connectFailed: "connectFailed" + case .welcome: "welcome" case .pairIntro: "pairIntro" + case .bluetoothPermission: "bluetoothPermission" case .scanning: "scanning" case .pairing: "pairing" case .paired: "paired" case .pairFailed(let failure): "pairFailed.\(failure)" case .radioBlocked(let block): "radioBlocked.\(block)" + case .setup: "setup" case .main: "main" } } } + +extension LaunchFlowView where Setup == EmptyView { + public init(model: LaunchFlowModel, @ViewBuilder main: () -> Main) { + self.init(model: model, setup: { EmptyView() }, main: main) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift index 3e3d57460..3426d4108 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift @@ -1,78 +1,5 @@ import SwiftUI -// The launch-side states around the pairing flow: bonded and quietly reconnecting, -// the bonded device unreachable, and the radio blocked. Dumb views. - -/// Bonded launch, connecting. Brief and non-blocking by contract: the flow model caps -/// it with `Timing.connectGrace` and always resolves to main. -struct LaunchConnectingView: View { - let deviceName: String - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - DeviceGlyphView(variant: .home(name: deviceName)) - .padding(.bottom, 34) - - HStack(spacing: 10) { - OBCSpinner() - .accessibilityHidden(true) - Text("Connecting to \(deviceName)") - .font(.system(.headline)) - .foregroundStyle(OBCTheme.ink) - .accessibilityIdentifier("launch.connectingTitle") - } - .padding(.bottom, 8) - - LaunchMessage("This can take a moment when the OBC wakes from sleep.") - } - } actions: { - HStack(spacing: 8) { - OBCLogo().frame(width: 24, height: 24) - PixelText("OpenBikeComputer", size: .label, color: OBCTheme.secondary) - } - .accessibilityHidden(true) - } - } -} - -/// The bonded device did not answer within the connect grace window: asleep, out of -/// range, or powered off. Never a trap: retry re-enters connecting, or head to the -/// routes, and the background connect keeps listening either way. -struct LaunchConnectFailedView: View { - let deviceName: String - let onRetry: () -> Void - let onGoToRoutes: () -> Void - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - // The device drawn dark and grey: it is there, but not answering. - DeviceGlyphView(variant: .home(name: deviceName)) - .grayscale(1) - .opacity(0.45) - .accessibilityHidden(true) - .padding(.bottom, 34) - - LaunchTitle("Can't reach \(deviceName)") - .accessibilityIdentifier("launch.connectFailedTitle") - .padding(.bottom, 8) - - LaunchMessage( - "It is asleep or out of range. The app connects on its own when \(deviceName) is nearby." - ) - } - } actions: { - Button("Try again", action: onRetry) - .buttonStyle(.obcPrimary) - .accessibilityIdentifier("launch.tryAgain") - Button("Open Library", action: onGoToRoutes) - .buttonStyle(.obcGhost) - .accessibilityIdentifier("launch.goToRoutes") - } - } -} - /// Radio off, or the state after the rider denied Bluetooth. Say which switch fixes it, and /// never trap the rider: the library stays reachable. struct RadioBlockedView: View { @@ -106,6 +33,9 @@ struct RadioBlockedView: View { Button("Open Settings", action: openSettings) .buttonStyle(.obcPrimary) .accessibilityIdentifier("radio.openSettings") + Button("Try again", action: onRetry) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("radio.tryAgain") } Button("Browse library", action: onBrowseLibrary) .buttonStyle(.obcGhost) @@ -136,14 +66,6 @@ struct RadioBlockedView: View { } } -#Preview("Connecting") { - LaunchConnectingView(deviceName: "Trailhead") -} - -#Preview("Can't reach") { - LaunchConnectFailedView(deviceName: "Trailhead", onRetry: {}, onGoToRoutes: {}) -} - #Preview("Bluetooth off") { RadioBlockedView(block: .off, onRetry: {}, onBrowseLibrary: {}) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift index f6373ed5e..85c37cf47 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift @@ -1,4 +1,5 @@ import SwiftUI +import OBCTransport // The pairing screens. Dumb views: copy and callbacks, no transport; // `LaunchFlowView` binds them to `LaunchFlowModel`. @@ -60,122 +61,50 @@ struct LaunchMessage: View { } } -/// The pairing prompt: the device's pairing card, three literal steps, then one action. -struct PairIntroView: View { - let onStart: () -> Void - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - DeviceGlyphView(variant: .passkey) - .padding(.bottom, 28) - - LaunchTitle("Pair your OBC") - .accessibilityIdentifier("pair.introTitle") - .padding(.bottom, 20) - - VStack(spacing: 14) { - step(1, "On the OBC, open **Settings ▸ Connections** and check that Bluetooth is on.") - step(2, "Keep the OBC near your phone.") - step(3, "Tap it in the list, then enter the code it shows.") - } - } - } actions: { - Button("Start pairing", action: onStart) - .buttonStyle(.obcPrimary) - .accessibilityIdentifier("pair.start") - } - } - - private func step(_ number: Int, _ text: LocalizedStringKey) -> some View { - HStack(alignment: .top, spacing: 13) { - Text("\(number)") - .font(.system(.footnote, weight: .semibold).monospacedDigit()) - .foregroundStyle(OBCTheme.surface) - .frame(width: 26, height: 26) - .background(OBCTheme.secondary, in: Circle()) - .obcFixedGeometryType() - Text(text) - .font(.system(.subheadline)) - .foregroundStyle(OBCTheme.ink) - .lineSpacing(3) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityElement(children: .combine) - } -} - -/// Scanning: the rings, and the found device slides in as a row to tap. +/// A scan with several nearby OBCs. Their advertised names also appear under the device QR code. struct PairScanningView: View { - let discovered: LaunchFlowModel.DiscoveredDevice? - let onTapDevice: () -> Void + let devices: [PairingDevice] + let onTapDevice: (PairingDevice) -> Void let onCancel: () -> Void var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { ZStack { - PulsingRings() + if devices.isEmpty { PulsingRings() } BluetoothTile() } - .frame(width: 200, height: 200) - .padding(.bottom, 8) - - LaunchTitle("Looking for your OBC") + .frame(width: 200, height: 180) + .padding(.bottom, 12) + LaunchTitle(devices.isEmpty ? "Looking for your OBC" : "Which OBC is yours?") .accessibilityIdentifier("pair.scanningTitle") + .padding(.bottom, 12) + LaunchMessage(devices.isEmpty + ? "Keep your OBC awake and nearby." + : "Choose the name shown under the QR code on your OBC.") .padding(.bottom, 24) - - if let discovered { - deviceRow(discovered) - .transition(.move(edge: .bottom).combined(with: .opacity)) + if !devices.isEmpty { + OBCGroupedSection { + ForEach(devices) { device in + OBCListRow(label: device.name, showsChevron: true) { onTapDevice(device) } + .accessibilityIdentifier("pair.deviceRow.\(device.id.uuidString)") + } + } } } - .animation(.spring(duration: 0.45), value: discovered) } actions: { Button("Cancel", action: onCancel) .buttonStyle(.obcGhost) .accessibilityIdentifier("pair.cancel") } } - - private func deviceRow(_ device: LaunchFlowModel.DiscoveredDevice) -> some View { - Button(action: onTapDevice) { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 9) - .fill(OBCTheme.fill) - .frame(width: 36, height: 36) - .overlay { - BluetoothRune() - .stroke(OBCTheme.ink, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .frame(width: 18, height: 18) - } - VStack(alignment: .leading, spacing: 2) { - Text(device.advertisedName) - .font(.system(.body, weight: .semibold)) - .foregroundStyle(OBCTheme.ink) - Text("Tap to pair") - .font(.system(.footnote)) - .foregroundStyle(OBCTheme.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - Image(systemName: "chevron.right") - .font(.system(.footnote, weight: .semibold)) - .foregroundStyle(OBCTheme.secondary) - } - .padding(.vertical, 14) - .padding(.horizontal, 16) - .frame(minHeight: 60) - .background(OBCTheme.surface, in: RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) - .contentShape(RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) - } - .buttonStyle(.plain) - .accessibilityIdentifier("pair.deviceRow") - } } /// The beat while pairing completes. On the real path the iOS pairing alert sits over this and /// asks for the code the device shows. struct PairingBackdropView: View { + var onCancel: () -> Void = {} + var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { @@ -183,29 +112,68 @@ struct PairingBackdropView: View { .padding(.bottom, 28) LaunchTitle("Enter the code the OBC shows") .accessibilityIdentifier("pair.pairingTitle") + .padding(.bottom, 12) + LaunchMessage("Use the iOS pairing window to enter the six-digit code.") } } actions: { + Button("Cancel", action: onCancel) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("pair.cancel") } } } -/// Paired: the device with its name, and one way forward. struct PairedView: View { let deviceName: String - let onContinue: () -> Void + @Binding var name: String + var saving = false + var error: String? + let onSave: () -> Void + let onKeepName: () -> Void + var canSave: Bool { !saving && !DeviceRenaming.normalized(name).isEmpty } var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { DeviceGlyphView(variant: .home(name: deviceName)) - .padding(.bottom, 34) - LaunchTitle("Paired with \(deviceName)") + .padding(.bottom, 28) + LaunchTitle("Your OBC is paired") .accessibilityIdentifier("pair.pairedTitle") + .padding(.bottom, 12) + LaunchMessage("Give it a name, or keep \(deviceName). You can change this later in Settings.") + .padding(.bottom, 24) + VStack(alignment: .leading, spacing: 8) { + Text("Device name") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(OBCTheme.ink) + TextField("Device name", text: $name) + .font(.system(.body)) + .foregroundStyle(OBCTheme.ink) + .padding(16) + .background(OBCTheme.surface, in: RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) + .autocorrectionDisabled() + .submitLabel(.continue) + .onSubmit { if canSave { onSave() } } + .disabled(saving) + .accessibilityIdentifier("pairing.name") + if let error { + Text(error) + .font(.system(.footnote)) + .foregroundStyle(OBCTheme.danger) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("pairing.nameError") + } + } } } actions: { - Button("Open Library", action: onContinue) + Button(saving ? "Saving name…" : "Continue", action: onSave) .buttonStyle(.obcPrimary) - .accessibilityIdentifier("pair.goToRoutes") + .disabled(!canSave) + .accessibilityIdentifier("pairing.saveName") + Button("Keep \(deviceName)", action: onKeepName) + .buttonStyle(.obcGhost) + .disabled(saving) + .accessibilityIdentifier("pairing.keepName") } } } @@ -264,20 +232,12 @@ struct PairFailedView: View { } } -#Preview("Prompt") { - PairIntroView(onStart: {}) -} - -#Preview("Scanning, found") { - PairScanningView(discovered: .init(name: "Trailhead"), onTapDevice: {}, onCancel: {}) -} - -#Preview("Pairing") { - PairingBackdropView() +#Preview("Scanning") { + PairScanningView(devices: [], onTapDevice: { _ in }, onCancel: {}) } #Preview("Paired") { - PairedView(deviceName: "Trailhead", onContinue: {}) + PairedView(deviceName: "Trailhead", name: .constant("Trailhead"), onSave: {}, onKeepName: {}) } #Preview("Timeout") { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift new file mode 100644 index 000000000..8de1b4596 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct WelcomeView: View { + let onStart: () -> Void + let onBrowse: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + DeviceGlyphView(variant: .home(name: "OBC")) + .padding(.bottom, 32) + LaunchTitle("Your next ride starts here") + .accessibilityIdentifier("onboarding.welcomeTitle") + .padding(.bottom, 12) + LaunchMessage("Send routes to your OBC. Bring your rides home.") + .padding(.bottom, 24) + Text("No account. No subscription. No cloud.") + .font(.system(.footnote, weight: .medium)) + .foregroundStyle(OBCTheme.ink) + .multilineTextAlignment(.center) + } + } actions: { + Button("Set up my OBC", action: onStart) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.getStarted") + Button("Browse the library first", action: onBrowse) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.browse") + } + } +} + +struct SwitchOnView: View { + let onFind: () -> Void + let onBack: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + DeviceGlyphView(variant: .welcome) + .padding(.bottom, 28) + LaunchTitle("Switch on your OBC") + .accessibilityIdentifier("onboarding.switchOnTitle") + .padding(.bottom, 12) + LaunchMessage("Pick a language on your OBC and follow its setup steps.") + .padding(.bottom, 22) + Label { + Text("When the QR code appears, scan it with your iPhone camera. Or find your OBC below.") + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "qrcode.viewfinder") + .font(.system(.title2)) + } + .font(.system(.subheadline)) + .foregroundStyle(OBCTheme.secondary) + .frame(maxWidth: 320) + .lineSpacing(3) + } + } actions: { + Button("Find my OBC", action: onFind) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("pair.start") + Button("Back", action: onBack) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.back") + } + } +} + +struct BluetoothPermissionView: View { + let onAllow: () -> Void + let onBack: () -> Void + let onBrowse: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + BluetoothTile() + .padding(.bottom, 32) + LaunchTitle("Connect with Bluetooth") + .accessibilityIdentifier("onboarding.bluetoothTitle") + .padding(.bottom, 12) + LaunchMessage("Bluetooth sends routes, rides and updates between your iPhone and your OBC.") + .padding(.bottom, 20) + LaunchMessage("Keep your OBC nearby. If iOS asks, allow Bluetooth access.") + } + } actions: { + Button("Continue", action: onAllow) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.allowBluetooth") + Button("Back", action: onBack) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.bluetoothBack") + Button("Browse library", action: onBrowse) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.browse") + } + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift index 8d02e4121..b9d2c62ea 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift @@ -98,6 +98,7 @@ public final class MainScreenModel { /// `ride(_:)`. @ObservationIgnored private var rideSummaries: [RideID: RideSummary] = [:] @ObservationIgnored private var started = false + @ObservationIgnored private var connectionRevision = 0 @ObservationIgnored private var streamTasks: [Task] = [] @ObservationIgnored private var loadTask: Task? /// Set when a reload is requested while `loadTask` is already reading. Bursts coalesce @@ -126,8 +127,7 @@ public final class MainScreenModel { private let legRouter: (any LegRouter)? /// The in-flight transfer ledger. Nil in tests and previews. @ObservationIgnored private let transferActivity: TransferActivity? - /// The in-flight identity read for the current connection. A reconnect replaces it and - /// never cancels it: a superseded read settles the same session-stable verdict. + /// Reconnect waits for the previous identity read to drain before starting another. @ObservationIgnored private var identityTask: Task? public static let trashRetentionDays = 30 @@ -207,19 +207,35 @@ public final class MainScreenModel { for await state in transport.state { guard let self else { return } connection = state - // A regained link, never the stream's replayed first value: re-read the lists - // and run the desired-name reconcile for a config write that never landed. - if state == .connected, let was = previous, was != .connected { + guard state != previous else { continue } + previous = state + connectionRevision += 1 + if state == .connected { + identityChecked = false + connectedScope = nil + lastRouteCatalog = nil + lastTripCatalog = nil + deviceRouteCRCs = [:] + deviceTripCRCs = [:] + refreshOnDeviceStates() + // Initial connection and reconnect share the same catalog/identity order. reload() - // Re-read identity because firmware or the mounted store can change. + let catalogs = loadTask + let priorIdentity = identityTask + let revision = connectionRevision identityTask = Task { [weak self] in - await self?.runIdentityCheck() - } - if let nameReconciler { - Task { await nameReconciler.reconcile() } + await priorIdentity?.value + await catalogs?.value + guard let self, connection == .connected, + connectionRevision == revision, !Task.isCancelled else { return } + await runIdentityCheck() + guard connection == .connected, connectionRevision == revision else { return } + await nameReconciler?.reconcile() } + } else { + reloadRequested = false + loadState = .loaded } - previous = state } }) streamTasks.append(Task { [weak self, transport] in @@ -246,22 +262,6 @@ public final class MainScreenModel { if connection == .connected { reload() } } }) - reload() - // Identity after the first library read, so a fault armed for the first read hits the - // lists and not this fetch. The same read carries the protocol-version check. - let firstLoad = loadTask - identityTask = Task { [weak self] in - await firstLoad?.value - await self?.runIdentityCheck() - } - // The reconnect edge never fires for the stream's replayed first value, so the launch - // connection reconciles here. Launched disconnected, the pass skips silently. - if let nameReconciler { - streamTasks.append(Task { - await firstLoad?.value - await nameReconciler.reconcile() - }) - } } deinit { @@ -276,7 +276,7 @@ public final class MainScreenModel { public func reload() { // Never decode an incompatible device's objects. The library-first content stays up // and the banner explains. - guard protocolMismatch == nil else { + guard connection == .connected, protocolMismatch == nil else { reloadRequested = false loadState = .loaded return @@ -294,13 +294,15 @@ public final class MainScreenModel { /// Drain coalesced catalog requests serially. Main-actor isolation stops the dirty bit and /// the `loadTask` retirement from racing a store-change callback. private func runReloadLoop() async { - while reloadRequested, !Task.isCancelled { + while reloadRequested, connection == .connected, !Task.isCancelled { reloadRequested = false + let revision = connectionRevision do { // Only the route catalog: Tracked is library-first, so its rows come from the // local library and device rides are pulled by Sync alone. let deviceRoutes = try await transport.listRoutes() guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } lastRouteCatalog = deviceRoutes reconcileOnDevice(with: deviceRoutes) // Fail closed: a failed trip read skips the reconcile, because treating it as @@ -308,15 +310,18 @@ public final class MainScreenModel { // duplicate device trip instead of replacing in place. if let deviceTrips = try? await transport.listTrips() { guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } lastTripCatalog = deviceTrips reconcileTripsOnDevice(with: deviceTrips) } + guard connectionRevision == revision else { continue } routes = plannedList() reloadTrips() rides = trackedList() loadState = .loaded } catch { guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } loadState = .failed } } @@ -327,11 +332,17 @@ public final class MainScreenModel { /// A missing scope or failed read leaves device writes disabled until the /// next successful connection. Local library browsing remains available. private func runIdentityCheck() async { + guard connection == .connected else { return } + let revision = connectionRevision + let wasIncompatible = protocolMismatch != nil // Unknown until proven, every connection: the device may have been // reinitialized (new StoreId) or swapped since the last read. connectedScope = nil await stampDeviceClock() - if let info = try? await transport.deviceInfo() { + guard connectionRevision == revision, !Task.isCancelled else { return } + let info = try? await transport.deviceInfo() + guard connectionRevision == revision, !Task.isCancelled else { return } + if let info { deviceName = info.name if case let .protocolMismatch(expected, found)? = OBCProtocol.versionMismatch(reportedBy: info.protocolVersion) { @@ -353,6 +364,7 @@ public final class MainScreenModel { reconcileTripsOnDevice(with: tripCatalog) } reloadTrips() + if wasIncompatible { reload() } } } @@ -759,6 +771,7 @@ public final class MainScreenModel { _ id: TripID, timing: TripUploadModel.Timing = TripUploadModel.Timing() ) -> TripUploadModel? { guard let trip = trip(id), let plan = planTripUpload(id) else { return nil } + let scope = connectedScope let days = dayRoutes(of: trip) var steps: [TripUploadModel.QueueStep] = [] // A reversed trip has a new key. The device must not keep the old trip object, with the @@ -818,7 +831,14 @@ public final class MainScreenModel { transport: transport, card: DeviceTripCard(name: trip.name, days: days.map { $0.summary(tripID: id) }), deviceName: deviceName, precheck: plan.precheck, steps: steps, - timing: timing, activity: transferActivity + timing: timing, activity: transferActivity, + verifyConnection: { [weak self] in + guard let self else { throw DeviceError.readFailed } + await identityTask?.value + try Task.checkCancellation() + guard connection == .connected, connectedScope != nil, + scope == nil || connectedScope == scope else { throw DeviceError.readFailed } + } ) } @@ -1309,7 +1329,7 @@ public final class MainScreenModel { /// The listed ride after `id` in time: the partner of Merge with next. public func nextRide(after id: RideID) -> RideSummary? { guard let ride = rides.first(where: { $0.id == id }) else { return nil } - return rides.filter { $0.date > ride.date }.min { $0.date < $1.date } + return rides.filter { $0.date > ride.date && $0.isDemo == ride.isDemo }.min { $0.date < $1.date } } /// Join the next ride onto this one. The time between the two does not count as moving time. diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift index 6b517205c..9b3b30ef5 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift @@ -17,6 +17,8 @@ public struct MainScreenView: View { private let onSelectRide: (RideSummary) -> Void private let onSettings: () -> Void private let onOpenTrash: () -> Void + private let showsReadyNote: Bool + private let onDismissReadyNote: () -> Void private let onPlanRoute: (() -> Void)? @State private var emptyStatePickerShown = false @@ -37,6 +39,8 @@ public struct MainScreenView: View { onSelectRide: @escaping (RideSummary) -> Void = { _ in }, onSettings: @escaping () -> Void = {}, onOpenTrash: @escaping () -> Void = {}, + showsReadyNote: Bool = false, + onDismissReadyNote: @escaping () -> Void = {}, onPlanRoute: (() -> Void)? = nil ) { self.model = model @@ -47,6 +51,8 @@ public struct MainScreenView: View { self.onSelectRide = onSelectRide self.onSettings = onSettings self.onOpenTrash = onOpenTrash + self.showsReadyNote = showsReadyNote + self.onDismissReadyNote = onDismissReadyNote self.onPlanRoute = onPlanRoute } @@ -64,6 +70,19 @@ public struct MainScreenView: View { onSettings: onSettings ) + if showsReadyNote { + OBCInlineBanner( + systemImage: "checkmark", + title: "Ready to ride", + message: "Hold BACK on your OBC to open the menu. You can change your setup in Settings.", + actionTitle: "Got it", + action: onDismissReadyNote + ) + .accessibilityIdentifier("onboarding.readyNote") + .padding(.horizontal, 16) + .padding(.top, 10) + } + // One banner at a time. A protocol mismatch outranks the rest: the link is up but // unusable for data, so it is neither a transfer nor an out-of-range story. if let mismatch = model.protocolMismatch { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift index 9dbd8c26a..78a2d7b0c 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift @@ -21,17 +21,20 @@ public final class RideSyncCoordinator { public var reason: Reason = .download public enum Reason: Equatable, Sendable { - case download, confirmationPending, unsupported, sourceUnavailable, refused + case download, archive, confirmationPending, unsupported, sourceUnavailable, refused } - /// Every reason but `download` is about the mark that tells the OBC a ride is saved on the - /// phone: the rides themselves are saved. public var title: String { - reason == .download ? "Sync interrupted." : "Rides saved on this phone." + switch reason { + case .download: "Sync interrupted." + case .archive: "Ride could not be saved." + default: "Rides saved on this phone." + } } public var message: String { switch reason { case .download: "Got \(landed) of \(total) rides." + case .archive: "Saved \(landed) of \(total) rides. The app could not save a ride in this phone's library. Your rides remain on the OBC." case .confirmationPending: "The OBC did not answer when the app marked them as synced, so the OBC does not show them as synced yet." case .unsupported: "This OBC cannot show rides as synced. Update its firmware." @@ -271,6 +274,7 @@ public final class RideSyncCoordinator { defer { dropWatch.cancel() } var landed = 0 var batchFailed = false + var failureReason: SyncInterruption.Reason = .download do { for try await downloaded in download.rides { try Task.checkCancellation() @@ -288,6 +292,7 @@ public final class RideSyncCoordinator { let decoded = ride.summary ride.summary = summary if ride.summary.trackPreview == nil { ride.summary.trackPreview = decoded.trackPreview } + ride.summary.isDemo = decoded.isDemo ride.summary.descentMeters = decoded.descentMeters ride.summary.avgHeartRate = decoded.avgHeartRate ride.summary.maxHeartRate = decoded.maxHeartRate @@ -296,7 +301,12 @@ public final class RideSyncCoordinator { ride.summary.maxPower = decoded.maxPower ride.summary.energyKJ = decoded.energyKJ } - let receipt = try library.archiveRide(ride) + let receipt: RideArchiveReceipt? + do { receipt = try library.archiveRide(ride) } + catch { + failureReason = .archive + throw error + } syncedRideIDs.insert(downloaded.id) onRideLanded(ride) landed += 1 @@ -321,7 +331,7 @@ public final class RideSyncCoordinator { syncState = .idle syncInterruption = SyncInterruption( landed: landed, total: fresh.count, - reason: batchFailed || outcome != .completed ? .download : confirmationFailure!) + reason: batchFailed || outcome != .completed ? failureReason : confirmationFailure!) return } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift new file mode 100644 index 000000000..387618e6b --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift @@ -0,0 +1,267 @@ +import SwiftUI +import UniformTypeIdentifiers +import OBCDomain +import OBCTransport + +/// Optional phone steps use the same firmware, import and sync operations as the Library. +public struct OnboardingFlowView: View { + private let progress: OnboardingProgress + private let firmware: FirmwareUpdateModel + private let library: MainScreenModel + private let onDemoRoute: () -> Void + private let onImport: ([URL]) -> Void + private let onUpdateAnswered: (String) -> Void + private let onFinish: () -> Void + @State private var showsFirmware = false + @State private var showsImporter = false + + public init( + progress: OnboardingProgress, + firmware: FirmwareUpdateModel, + library: MainScreenModel, + onDemoRoute: @escaping () -> Void, + onImport: @escaping ([URL]) -> Void, + onUpdateAnswered: @escaping (String) -> Void = { _ in }, + onFinish: @escaping () -> Void + ) { + self.progress = progress + self.firmware = firmware + self.library = library + self.onDemoRoute = onDemoRoute + self.onImport = onImport + self.onUpdateAnswered = onUpdateAnswered + self.onFinish = onFinish + } + + public var body: some View { + Group { + switch progress.stage { + case .inactive, .sensors: sensors + case .update: update + case .route: route + case .ride: ride + case .done: Color.clear + } + } + .task { + progress.begin() + library.start() + firmware.start() + skipCurrentFirmware() + } + .onChange(of: firmware.checkState) { _, _ in skipCurrentFirmware() } + .onChange(of: firmware.runningVersion) { _, _ in skipCurrentFirmware() } + .onChange(of: library.connectedScope) { _, _ in skipCurrentFirmware() } + .onChange(of: firmware.phase) { _, phase in + if phase == .done { + showsFirmware = false + if progress.stage == .update { progress.move(to: .route) } + } + } + .sheet(isPresented: $showsFirmware, onDismiss: { firmware.start() }) { + NavigationStack { + FirmwareUpdateView(model: firmware) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to setup") { showsFirmware = false } + .disabled(firmware.phase == .transferring) + } + } + } + .interactiveDismissDisabled(firmware.phase == .transferring) + } + .fileImporter( + isPresented: $showsImporter, + allowedContentTypes: [UTType(filenameExtension: "gpx") ?? .xml, + UTType(filenameExtension: "tcx") ?? .xml], + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result { onImport(urls) } + } + } + + private var sensors: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + DeviceGlyphView(variant: .home(name: library.deviceName)) + .accessibilityHidden(true) + LaunchTitle("Look at your OBC") + .accessibilityIdentifier("onboarding.sensorsTitle") + LaunchMessage("Set up sensors and effort zones on the OBC. You can skip either step and add them later in Settings.") + VStack(alignment: .leading, spacing: 18) { + tip("heart", "Heart rate", "Wear the strap to wake it.") + tip("bolt", "Power and cadence", "Turn the cranks to wake them.") + } + LaunchMessage("Sensors connect to the OBC itself. Continue here when you are ready.") + } + } actions: { + Button("Continue") { + progress.move(to: .update) + skipCurrentFirmware() + } + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.sensorsContinue") + } + } + + private var update: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "arrow.down.circle") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle(updateTitle) + .accessibilityIdentifier("onboarding.updateTitle") + LaunchMessage(updateMessage) + if firmware.checkState == .checking { + ProgressView("Checking for an update") + } else if firmware.updateStatus == .available { + Text("\(firmware.runningVersionLine) → \(firmware.latestVersionLine)") + .font(.headline.monospacedDigit()).foregroundStyle(OBCTheme.ink) + } + } + } actions: { + if firmware.updateStatus == .available || library.protocolMismatch.map({ $0.found < $0.expected }) == true { + Button("View update") { + answerVisibleUpdate() + showsFirmware = true + } + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.updateNow") + } + Button(library.protocolMismatch == nil ? "Later" : "Finish setup") { + answerVisibleUpdate() + if library.protocolMismatch == nil { progress.move(to: .route) } + else { finish() } + } + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.updateLater") + } + } + + private var updateTitle: String { + if let mismatch = library.protocolMismatch { + return mismatch.found > mismatch.expected ? "Update the app" : "An update is needed" + } + return firmware.checkState == .checking ? "Check your firmware" : "Update your OBC" + } + + private var updateMessage: String { + if let mismatch = library.protocolMismatch { + return mismatch.found > mismatch.expected + ? "This OBC needs a newer app for routes and ride sync. You can still ride and finish setup." + : "Routes and ride sync need newer OBC firmware. Riding still works, and you can finish setup now." + } + return "Install the latest improvements, or do this later in Settings. Confirm the installation on your OBC." + } + + private var route: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle("Send your first route") + .accessibilityIdentifier("onboarding.routeTitle") + LaunchMessage("Try the Grimsel Pass demo route, or import a GPX or TCX file. Your OBC shows a confirmation when the route arrives.") + if !canTransfer { connectionMessage } + } + } actions: { + Button("Send demo route", action: onDemoRoute) + .buttonStyle(.obcPrimary).disabled(!canTransfer) + .accessibilityIdentifier("onboarding.demoRoute") + Button("Import a route") { showsImporter = true } + .buttonStyle(.obcGhost).disabled(!canTransfer) + .accessibilityIdentifier("onboarding.importRoute") + Button("Skip for now") { progress.move(to: .ride) } + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.routeSkip") + } + } + + private var ride: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "arrow.down.to.line") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle(hasDemoRide ? "Your demo ride is here" : "Bring a ride home") + .accessibilityIdentifier("onboarding.rideTitle") + LaunchMessage(hasDemoRide + ? "Find the Grimsel Pass ride in your Library. It is marked Demo and stays out of your totals." + : "Sync the demo ride from your OBC to see how rides reach your phone. It stays out of your totals.") + if library.sync.syncState == .syncing { + ProgressView("Syncing rides…") + } else if let interruption = library.sync.syncInterruption { + LaunchMessage(interruption.message) + } else if library.sync.lastSyncCount != nil && !hasDemoRide { + LaunchMessage("No demo ride was found. You can sync your own rides from the Library after your first ride.") + } + if !canTransfer { connectionMessage } + } + } actions: { + if !hasDemoRide { + Button(library.sync.syncInterruption?.actionTitle ?? "Sync demo ride") { + if library.sync.syncInterruption != nil { library.sync.resumeSync() } + else { library.sync.sync() } + } + .buttonStyle(.obcPrimary) + .disabled(!canTransfer || library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.syncRide") + } + if hasDemoRide { + Button("Open Library", action: finish) + .buttonStyle(.obcPrimary) + .disabled(library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.finish") + } else { + Button("Skip for now", action: finish) + .buttonStyle(.obcGhost) + .disabled(library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.finish") + } + } + } + + private var canTransfer: Bool { + library.connection == .connected && library.connectedScope != nil && library.protocolMismatch == nil + } + + private var hasDemoRide: Bool { library.rides.contains(where: \.isDemo) } + + private var connectionMessage: some View { + LaunchMessage(library.protocolMismatch != nil + ? "The app and OBC need compatible versions before routes or rides can be sent. You can skip this step." + : "Keep your OBC on and nearby. You can skip this step and connect later.") + } + + private func skipCurrentFirmware() { + guard progress.stage == .update, library.connectedScope != nil, + library.protocolMismatch == nil, firmware.hasUpdateAnswer, + firmware.checkState != .checking, firmware.updateStatus != .available else { return } + progress.move(to: .route) + } + + private func finish() { + firmware.stop() + progress.finish() + onFinish() + } + + private func answerVisibleUpdate() { + guard firmware.checkState != .checking, firmware.updateStatus == .available, + let version = firmware.latestRelease?.version else { return } + onUpdateAnswered(version) + } + + private func tip(_ icon: String, _ title: String, _ message: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon).foregroundStyle(OBCTheme.tint).frame(width: 24) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline).foregroundStyle(OBCTheme.ink) + Text(message).font(.subheadline).foregroundStyle(OBCTheme.secondary) + } + } + .accessibilityElement(children: .combine) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift new file mode 100644 index 000000000..ae775765a --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift @@ -0,0 +1,56 @@ +import Foundation +import Observation + +@MainActor @Observable +public final class OnboardingProgress { + public enum Stage: String, Sendable { + case inactive, sensors, update, route, ride, done + } + + public private(set) var stage: Stage + public private(set) var showsReadyNote: Bool + private let defaults: UserDefaults? + private static let stageKey = "obc.onboarding.stage" + private static let readyKey = "obc.onboarding.ready" + + /// A nil store keeps previews and simulator scenarios independent of previous launches. + public init(defaults: UserDefaults? = nil) { + self.defaults = defaults + stage = defaults?.string(forKey: Self.stageKey).flatMap(Stage.init(rawValue:)) ?? .inactive + showsReadyNote = defaults?.bool(forKey: Self.readyKey) ?? false + } + + public var isPending: Bool { stage != .inactive && stage != .done } + + public func begin() { + guard !isPending else { return } + move(to: .sensors) + dismissReadyNote() + } + + public func move(to stage: Stage) { + self.stage = stage + defaults?.set(stage.rawValue, forKey: Self.stageKey) + } + + public func finish() { + move(to: .done) + showsReadyNote = true + defaults?.set(true, forKey: Self.readyKey) + } + + public func dismissReadyNote() { + showsReadyNote = false + defaults?.set(false, forKey: Self.readyKey) + } + + public func replay() { + move(to: .sensors) + dismissReadyNote() + } + + public func reset() { + move(to: .inactive) + dismissReadyNote() + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift index 53bcf7217..fbb538621 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift @@ -143,10 +143,7 @@ public final class SettingsModel { public func rename(to newName: String) -> Bool { // Cap at the device's name limit, so the app-side name matches what the codec writes, and // trim again in case truncation left a trailing space. - let trimmed = newName - .trimmingCharacters(in: .whitespacesAndNewlines) - .truncatedToUTF8Bytes(DeviceConfig.maxNameUTF8Bytes) - .trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = DeviceRenaming.normalized(newName) guard !trimmed.isEmpty, canRename else { return false } deviceName = trimmed bondStore.save(BondRecord(deviceName: trimmed)) @@ -155,9 +152,7 @@ public final class SettingsModel { // The name rides in the config blob, so read-modify-write and the other fields // survive the rename. do { - var config = try await transport.readConfig() - config.name = trimmed - try await transport.writeConfig(config) + try await DeviceRenaming.save(trimmed, to: transport) } catch { // Either leg failing means the device never got the name. Flag it once for the // view's toast; the reconcile pass self-heals on the next connect, and the bond diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift index a0b060628..477141fd5 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift @@ -9,6 +9,7 @@ public struct SettingsView: View { /// Push the firmware-update screen. `nil` leaves the Update firmware row out (previews, and /// any wiring that does not host the update screen). private let onOpenFirmwareUpdate: (() -> Void)? + private let onReplaySetup: (() -> Void)? /// Debug-only: five taps on the App version row open the mock dev panel. `nil` in /// Release wiring, where the gesture goes nowhere. @@ -27,10 +28,12 @@ public struct SettingsView: View { public init( model: SettingsModel, onOpenFirmwareUpdate: (() -> Void)? = nil, + onReplaySetup: (() -> Void)? = nil, onOpenDevPanel: (() -> Void)? = nil ) { self.model = model self.onOpenFirmwareUpdate = onOpenFirmwareUpdate + self.onReplaySetup = onReplaySetup self.onOpenDevPanel = onOpenDevPanel } @@ -181,6 +184,17 @@ public struct SettingsView: View { .accessibilityIdentifier("settings.offlineMaps") } OBCAppearanceRow() + if let onReplaySetup { + OBCListRow( + icon: "arrow.counterclockwise", + iconColor: OBCTheme.tint, + label: "Run setup again", + showsChevron: true, + showsDivider: false, + action: onReplaySetup + ) + .accessibilityIdentifier("settings.replaySetup") + } } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift index 4736bfcf4..25a323807 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift @@ -42,7 +42,7 @@ public final class TripUploadModel: Identifiable { /// One queue step: a skipped day with no bytes, or a transfer of a day route or the trip object. /// `makeTransfer` is evaluated at execution time, so the trip-object step reads the day route ids - /// the just-committed days landed under. It returns nil to degenerate to a skip. + /// the just-committed days landed under. An unavailable required transfer fails the queue. public struct QueueStep: Sendable { let title: String let skip: Bool @@ -102,6 +102,7 @@ public final class TripUploadModel: Identifiable { private let steps: [QueueStep] private let precheck: TripUploadPrecheck private let timing: Timing + private let verifyConnection: @MainActor @Sendable () async throws -> Void @ObservationIgnored private let activity: TransferActivity? @ObservationIgnored private var activityToken: TransferActivity.Token? @ObservationIgnored private var currentHandle: TransferHandle? @@ -118,7 +119,8 @@ public final class TripUploadModel: Identifiable { precheck: TripUploadPrecheck, steps: [QueueStep], timing: Timing = Timing(), - activity: TransferActivity? = nil + activity: TransferActivity? = nil, + verifyConnection: @escaping @MainActor @Sendable () async throws -> Void = {} ) { self.transport = transport self.card = card @@ -128,6 +130,7 @@ public final class TripUploadModel: Identifiable { self.stepCount = steps.count self.timing = timing self.activity = activity + self.verifyConnection = verifyConnection self.phase = .uploading } @@ -252,6 +255,11 @@ public final class TripUploadModel: Identifiable { stepIndex += 1 continue } + do { try await verifyConnection() } + catch { + fail(error) + return + } if let run = step.run { do { try await run() @@ -265,10 +273,8 @@ public final class TripUploadModel: Identifiable { continue } guard let (handle, committedCRC) = step.makeTransfer?() else { - // Nothing resolvable to send, such as a trip with no on-device days, so treat it - // as a skip and move on. - stepIndex += 1 - continue + fail(DeviceError.readFailed) + return } currentHandle = handle phase = .uploading @@ -282,6 +288,11 @@ public final class TripUploadModel: Identifiable { switch outcome { case .completed: let objectID = await handle.assignedObjectID + do { try await verifyConnection() } + catch { + fail(error) + return + } step.commit?(objectID, committedCRC) committedCount += 1 stepIndex += 1 @@ -303,6 +314,12 @@ public final class TripUploadModel: Identifiable { shouldDismiss = true } + private func fail(_ error: Error) { + failure = .device(error as? DeviceError ?? .writeFailed) + phase = .failed + setActive(false) + } + private func watchProgress(_ handle: TransferHandle) { progressWatcher?.cancel() progressWatcher = Task { [weak self] in diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift index 725329ff3..c20b7fef0 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift @@ -8,6 +8,7 @@ final class MockBondStoreTests: XCTestCase { func testPairingScenariosBootUnbonded() { let unbonded: [Scenario] = [ .noDevice, .pairingTimeout, .pairingRejected, .bluetoothOff, .permissionDenied, + .onboarding, .onboardingNearby, .onboardingUpdate, .onboardingUpdateNeeded, ] for scenario in Scenario.allCases { XCTAssertEqual( diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift index c4b23bf28..9d4e2b176 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift @@ -141,7 +141,7 @@ final class MockLaunchOptionsTests: XCTestCase { func testSampleRouteFileServesEveryKind() { let names: [SampleRouteFile.Kind: [String]] = [ .gpx: ["sample-import.gpx"], .tcx: ["sample-import.tcx"], .bad: ["packing-list.pdf"], - .grimsel: ["website-import.gpx"], .trip: ["sample-import.gpx", "website-import.gpx"], + .grimsel: ["grimsel-pass.gpx"], .trip: ["sample-import.gpx", "grimsel-pass.gpx"], ] for (kind, expected) in names { XCTAssertEqual(SampleRouteFile.files(kind).map(\.fileName), expected, "\(kind) sample must load") diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift new file mode 100644 index 000000000..c723a90e2 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +import OBCDomain +import OBCTransport +import OBCMock + +struct OnboardingFixturesTests { + @Test func nearbyScenarioSelectsTheNamedPeer() async throws { + let control = MockControl(scenario: .onboardingNearby) + control.latency = .zero + let transport = MockTransport(control: control) + let candidates = try await transport.scanForPairing() + #expect(candidates.map(\.name) == ["OBC-7A2F", "OBC-9C41"]) + #expect(Set(candidates.map(\.id)).count == 2) + try await transport.discover(try #require(candidates.last)) + try await transport.authenticate() + #expect(try await transport.deviceInfo().name == "OBC-9C41") + control.apply(.onboarding) + #expect(control.pairingDevices == nil) + #expect(try await transport.scanForPairing().map(\.name) == ["OBC-7A2F"]) + } + + @Test(arguments: [Scenario.onboardingUpdate, .onboardingUpdateNeeded]) + func localManifestAndContainerExerciseTheNormalUpdater(_ scenario: Scenario) async throws { + let control = MockControl(scenario: scenario) + let expectedProtocol = scenario == .onboardingUpdateNeeded ? OBCProtocol.version - 1 : OBCProtocol.version + #expect(control.deviceInfo.protocolVersion == expectedProtocol) + let checker = try #require(OnboardingFixtures.updateChecker(for: scenario)) + let cached = try #require(checker.cachedCheck()) + let release = try #require(cached.release) + #expect(FirmwareVersion.updateStatus(running: control.deviceInfo.firmwareVersion, latest: release.version) == .available) + #expect(try await checker.check().release == release) + let downloaded = try await checker.download(release) + let staged = try StagedFirmware.validate(downloaded) + #expect(staged.version == "0.5.0") + } +} diff --git a/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift index 1e879c3f0..56bb3a308 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift @@ -55,18 +55,29 @@ struct ArchiveReceiptTests { #expect(await link.restores == 0) } - @Test func cancelledReceiveAndQueuedReceiptDoNotWriteAgain() async throws { + @Test(arguments: 0..<6) + func cancelledReceiveAndQueuedOperationDoNotWriteAgain(operation: Int) async throws { let link = ReceiptLink(steps: [.park, .reply(0)]) let client = TransferClient(link: link) let first = Task { try await send(client, store: link.store) } await link.waitUntilParked() - let queued = Task { try await send(client, store: link.store) } + let queued = Task { + switch operation { + case 0: _ = try await send(client, store: link.store) + case 1: _ = try await client.get(objectID: ObjectID(rawValue: 1)) + case 2: _ = try await client.put(Data([1]), kind: .route, displayName: "Route") + case 3: _ = try await client.list() + case 4: _ = try await client.remove(objectID: ObjectID(rawValue: 1), expectedRevision: Revision(rawValue: 1)) + default: _ = try await client.status(objectID: ObjectID(rawValue: 1), revision: Revision(rawValue: 1)) + } + } await Task.yield() queued.cancel() first.cancel() await #expect(throws: CancellationError.self) { try await first.value } await #expect(throws: CancellationError.self) { try await queued.value } #expect(await link.frames.filter { $0.opcode == .archiveRide }.count == 1) + #expect(await link.frames.count == 2) #expect(try await send(client, store: link.store).timestamp == 0) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift index a069ecc77..e7b2b99e5 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift @@ -28,11 +28,13 @@ struct RideArchiveTests { ]) } - @Test func archiveReopensWithAllFieldsAndExactSource() throws { + @Test(arguments: [false, true]) + func archiveReopensWithAllFieldsAndExactSource(isDemo: Bool) throws { let dir = try directory() defer { try? FileManager.default.removeItem(at: dir) } let store = FileLibraryStore(directory: dir) - let ride = ride() + var ride = ride() + ride.summary.isDemo = isDemo let receipt = try #require(try store.archiveRide(ride)) #expect(receipt.source == ride.summary.source) let reopened = FileLibraryStore(directory: dir) @@ -72,6 +74,31 @@ struct RideArchiveTests { #expect(reopened.archivedRideSource(next.id) == next.summary.source) } + @Test func absentDemoFlagKeepsArchiveProofAndPermitsReplacement() throws { + let dir = try directory() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileLibraryStore(directory: dir) + let original = ride() + _ = try store.archiveRide(original) + let rideDir = try #require(FileManager.default.contentsOfDirectory( + at: dir.appendingPathComponent("rides"), includingPropertiesForKeys: nil).first) + let manifestURL = rideDir.appendingPathComponent("summary.json") + var manifest = try #require(JSONSerialization.jsonObject( + with: Data(contentsOf: manifestURL)) as? [String: Any]) + var summary = try #require(manifest["summary"] as? [String: Any]) + summary.removeValue(forKey: "isDemo") + manifest["summary"] = summary + try JSONSerialization.data(withJSONObject: manifest).write(to: manifestURL) + + let reopened = FileLibraryStore(directory: dir) + #expect(reopened.rideSummaries() == [original.summary]) + #expect(reopened.archivedRideSource(original.id) == original.summary.source) + #expect(reopened.ridePoints(original.id) == original.points) + let next = ride(revision: 2) + _ = try reopened.archiveRide(next) + #expect(reopened.archivedRideSource(next.id) == next.summary.source) + } + @Test func historyAndMissingPointsAreNotArchiveProof() throws { let dir = try directory() defer { try? FileManager.default.removeItem(at: dir) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift index 76dc78612..33f262e8a 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift @@ -26,6 +26,7 @@ struct RideCodecVectorTests { let ride = try RideObjectCodec.decode(bytes, id: RideID("42")) let summary = ride.summary #expect(summary.name == "Sensor Ride") + #expect(summary.isDemo) #expect(summary.date == Date(timeIntervalSince1970: 1_751_460_000)) #expect(summary.distanceMeters == 12_345) #expect(summary.movingTime == 3_600) @@ -59,9 +60,9 @@ struct RideCodecVectorTests { @Test func rejectsBadFooterAndReservedSampleFlags() throws { let bytes = try vector() let footer = bytes.count - RideObjectCodec.footerLength - // Reserved byte, a day past the count, a bike type past the four, trip-name padding, and + // Unknown flags, a day past the count, a bike type past the four, trip-name padding, and // the reserved byte after the max heart rate limit. - for (offset, value) in [(33, 1), (98, 3), (100, 4), (149, 1), (151, 1)] as [(Int, UInt8)] { + for (offset, value) in [(33, 2), (98, 3), (100, 4), (149, 1), (151, 1)] as [(Int, UInt8)] { var badFooter = bytes badFooter[footer + offset] = value #expect(throws: (any Error).self) { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift index 8cdfb8300..40b9a4701 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift @@ -32,6 +32,20 @@ struct RideEditTests { return store } + @Test + func demoEditsStayMarkedAndCannotMergeWithRecordedRides() throws { + var demo = Self.ride("demo", start: t0, seconds: 60) + demo.summary.isDemo = true + let real = Self.ride("real", start: t0.addingTimeInterval(61), seconds: 60) + let store = store(demo, real) + #expect(!RideEdit.suggestsMerge(demo, real)) + #expect(!store.mergeRides(demo.summary, real.id)) + #expect(!store.mergeRides(real.summary, demo.id)) + #expect(store.trimRide(demo.id, to: t0...t0.addingTimeInterval(30), summary: demo.summary)) + #expect(try #require(store.rideSummaries().first { $0.id == demo.id }).isDemo) + #expect(RideTotals(store.rideSummaries()) == RideTotals([real.summary])) + } + @Test func statsEstimateTheEnergyFromTheStoredPointsAndCountTheDescent() { // One point every 5 s at 500 W, one of them without power. Each interval counts at most diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift index ab82d3176..d11b304d9 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift @@ -55,4 +55,15 @@ struct RideLibraryTests { #expect(byType[0].totals.distanceMeters == 120_000) #expect(byType[0].totals.rideCount == 2) } + @Test + func demoRemainsInTheLibraryButDoesNotCountInTotals() { + var demo = ride("Grimsel Pass", "2026-01-01T09:00:00Z", km: 15, hours: 2, climb: 700) + demo.isDemo = true + let recorded = ride("mine", "2026-08-01T09:00:00Z", km: 10, climb: 100, type: .gravel) + #expect(RideFilter(year: 2026).includes(demo, calendar: zurich)) + #expect(RideTotals([demo, recorded]) == RideTotals([recorded])) + #expect(RideTotals([demo]).rideCount == 0) + #expect(RideTotals.byBikeType([demo, recorded]).map(\.bikeType) == [.gravel]) + } + } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift index 726d7b55a..9e7b22102 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift @@ -113,6 +113,40 @@ struct FirmwareUpdateCheckTests { #expect(store.loadCheck()?.release?.version == "1.4.0", "the refreshed answer is cached") } + @Test func reconnectRefreshesTheVersionAfterAnInitialReadFailure() async throws { + let cached = UpdateCheckRecord( + release: FirmwareRelease( + version: "1.4.0", bytes: 10, sha256: String(repeating: "a", count: 64), + url: Self.containerURL + ), checkedAt: Date() + ) + let (model, transport, fetcher, _) = makeModel(cached: cached) + transport.deviceInfoError = .notConnected + model.start() + defer { model.stop() } + try await waitFor("initial version read") { transport.deviceInfoReads == 1 } + #expect(!model.hasUpdateAnswer) + + transport.push(.outOfRange) + try await waitFor("disconnected") { model.connection == .outOfRange } + transport.deviceInfoError = nil + transport.push(.connected) + try await waitFor("version after reconnect") { model.hasUpdateAnswer } + #expect(model.updateStatus == .available) + #expect(model.deviceSerial == "OBC-001") + + transport.push(.outOfRange) + try await waitFor("disconnected again") { model.connection == .outOfRange } + #expect(model.runningVersion == "1.3.0") + #expect(model.deviceSerial == "OBC-001") + #expect(model.canDownloadUpdate) + transport.fwVersion = "1.4.0" + transport.push(.connected) + try await waitFor("new running version") { model.runningVersion == "1.4.0" } + #expect(model.updateStatus == .current) + #expect(fetcher.requested.isEmpty) + } + @Test func aManualCheckReAsksEvenWithAFreshCache() async throws { let payload = container(version: "1.5.0") let cached = UpdateCheckRecord( @@ -385,6 +419,8 @@ private final class StubTransport: DeviceLink, DeviceUpdates, @unchecked Sendabl private var stateConts: [AsyncStream.Continuation] = [] private var lastState: ConnectionState = .connected var fwVersion = "1.3.0" + var deviceInfoError: DeviceError? + var deviceInfoReads = 0 var installResult: FirmwareInstallResult = .accepted private var uploadProgress: AsyncStream.Continuation? @@ -408,7 +444,9 @@ private final class StubTransport: DeviceLink, DeviceUpdates, @unchecked Sendabl } func deviceInfo() async throws -> DeviceInfo { - DeviceInfo(name: "Trailhead", firmwareVersion: fwVersion) + deviceInfoReads += 1 + if let deviceInfoError { throw deviceInfoError } + return DeviceInfo(name: "Trailhead", firmwareVersion: fwVersion, serial: "OBC-001") } func uploadFirmware(_ container: Data) -> TransferHandle { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift index 6cc276765..4993c57a7 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift @@ -1,274 +1,282 @@ -import XCTest +import Foundation +import Testing import OBCDomain import OBCMock import OBCTransport @testable import OBCUI -/// The launch and pairing state machine driven through `MockTransport` scenarios: every design -/// branch, plus the non-blocking guarantees (out of range lands on main; a silent device caps the -/// grace window onto connect-failed instead of spinning forever). @MainActor -final class LaunchFlowModelTests: XCTestCase { - /// Short pacing so the timers fire in test time, with enough slack that they never race a - /// healthy mock op. - private static let fastTiming = LaunchFlowModel.Timing( - connectGrace: .seconds(2), - scanTimeout: .seconds(2), - pairingBeat: .milliseconds(10) - ) - - private func makeModel( - _ scenario: Scenario, - timing: LaunchFlowModel.Timing = fastTiming +struct LaunchFlowModelTests { + private static let timing = LaunchFlowModel.Timing( + scanTimeout: .seconds(2), pairingBeat: .zero) + + private func make( + _ scenario: Scenario = .noDevice, + timing: LaunchFlowModel.Timing = Self.timing, + pending: @escaping @MainActor () -> Bool = { false } ) -> (LaunchFlowModel, MockControl) { let control = MockControl(scenario: scenario) control.latency = .zero - let model = LaunchFlowModel( - transport: MockTransport(control: control), - bondStore: MockBondStore(control: control), - timing: timing - ) - return (model, control) + return (LaunchFlowModel( + transport: MockTransport(control: control), bondStore: MockBondStore(control: control), + timing: timing, onboardingPending: pending), control) } - // MARK: The launch branch - - func testFirstRunBranchesToPairIntro() { - let (model, _) = makeModel(.noDevice) - model.start() - XCTAssertEqual(model.phase, .pairIntro) + private func wait(until condition: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !condition(), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(5)) } + #expect(condition()) } - func testForgetDeviceReturnsToPairIntro() async throws { - let (model, _) = makeModel(.happyPath) + private func pair(_ model: LaunchFlowModel) async throws { model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - - model.forgetDevice() - XCTAssertEqual(model.phase, .pairIntro) + model.startPairing() + model.allowBluetooth() + try await wait { if case .paired = model.phase { true } else { false } } } - func testBondedColdLaunchShowsConnectingThenMain() async throws { - let (model, control) = makeModel(.happyPath) - control.connection = .disconnected // cold boot: bonded but link down + @Test func welcomeAndPermissionPrecedeRadioUse() { + let (model, control) = make() model.start() - XCTAssertEqual(model.phase, .connecting(deviceName: "Trailhead")) - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - XCTAssertEqual(control.connection, .connected) + #expect(model.phase == .welcome) + model.showSwitchOn() + #expect(model.phase == .pairIntro) + model.startPairing() + #expect(model.phase == .bluetoothPermission) + #expect(control.connection == .disconnected) + #expect(!control.bonded) + model.browseLibrary() + #expect(model.phase == .main) } - func testBondedLaunchAlreadyConnectedGoesStraightToMain() async throws { - let (model, _) = makeModel(.happyPath) - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } + @Test func qrSkipsSwitchOnAndPreservesExistingBond() async throws { + let (fresh, _) = make() + fresh.openPairingLink() + #expect(fresh.phase == .bluetoothPermission) + fresh.start() + #expect(fresh.phase == .bluetoothPermission) + let (bonded, control) = make(.happyPath) + bonded.openPairingLink() + try await wait { bonded.phase == .main } + #expect(control.bonded) + bonded.openPairingLink() + #expect(bonded.phase == .main) } - func testOutOfRangeLandsOnMainNotAnError() async throws { - let (model, control) = makeModel(.outOfRange) - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - // No connect attempt: the degraded link is the banner's story. - XCTAssertEqual(control.connection, .outOfRange) + @Test(arguments: [ + "http://openbikecomputer.com/app", "https://other.example/app", + "https://openbikecomputer.com/app/", "https://openbikecomputer.com/app?device=123", + "https://openbikecomputer.com/app#pair", "https://user@openbikecomputer.com/app", + "https://openbikecomputer.com:443/app", "file:///app", "https://openbikecomputer.com/application" + ]) + func unrelatedLinksDoNotStartPairing(_ value: String) throws { + #expect(!LaunchFlowModel.acceptsPairingLink(try #require(URL(string: value)))) } - func testBondedConnectFailureStillLandsOnMain() async throws { - let (model, control) = makeModel(.happyPath) - control.connection = .disconnected - control.radio = .off // connect() will throw — must degrade, not error - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } + @Test func fixedLinkIsAccepted() throws { + #expect(LaunchFlowModel.acceptsPairingLink(try #require(URL(string: "https://openbikecomputer.com/app")))) } - func testConnectGraceExpiryShowsConnectFailedAndRoutesStayReachable() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .seconds(30) // connect() parks far past the cap - model.start() - try await waitFor("connect-failed despite a hung connect", timeout: .seconds(5)) { - model.phase == .connectFailed(deviceName: "Trailhead") - } - - model.browseLibrary() - XCTAssertEqual(model.phase, .main) + @Test func oneCandidatePairsDirectlyThenHandsOffToSetup() async throws { + let (model, control) = make() + try await pair(model) + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(control.bonded) + #expect(model.deviceNameDraft == "Trailhead") + model.finishPairing() + #expect(model.phase == .setup) + model.finishSetup() + #expect(model.phase == .main) } - func testRetryConnectFromConnectFailedLandsOnMainOnceReachable() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .seconds(30) - model.start() - try await waitFor("connect-failed", timeout: .seconds(5)) { model.phase == .connectFailed(deviceName: "Trailhead") } - - control.connection = .connected // the device woke up / came into range - model.retryConnect() - XCTAssertEqual(model.phase, .connecting(deviceName: "Trailhead")) - try await waitFor("main after retry", timeout: .seconds(5)) { model.phase == .main } + @Test func multipleCandidatesRequireExactSelection() async throws { + let (model, control) = make() + let first = PairingDevice(id: UUID(), name: "OBC-First") + let second = PairingDevice(id: UUID(), name: "OBC-Second") + control.pairingDevices = [first, second] + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == .scanning(devices: [first, second]) } + #expect(!control.bonded) + model.confirmPairing(PairingDevice(id: UUID(), name: first.name)) + #expect(model.phase == .scanning(devices: [first, second])) + model.confirmPairing(second) + model.confirmPairing(first) + try await wait { model.phase == .paired(deviceName: "OBC-Second") } + #expect(control.deviceInfo.name == "OBC-Second") + #expect(control.bondedName == "OBC-Second") } - func testLateConnectWhileOnConnectFailedAdvancesToMain() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .milliseconds(300) // slower than the grace, but finite - model.start() - try await waitFor("connect-failed first", timeout: .seconds(5)) { model.phase == .connectFailed(deviceName: "Trailhead") } - try await waitFor("main once the late connect lands", timeout: .seconds(5)) { model.phase == .main } + @Test func namingWritesExistingConfigAndPersistsOnlySuccess() async throws { + let (model, control) = make() + try await pair(model) + let original = control.fixtures.config + model.deviceNameDraft = " New bike " + model.saveNameAndContinue() + try await wait { model.phase == .setup } + var expected = original + expected.name = "New bike" + #expect(control.fixtures.config == expected) + #expect(control.bondedName == "New bike") } - // MARK: The pairing flow - - func testPairingHappyPathThroughAllScreens() async throws { - let (model, control) = makeModel(.noDevice) - model.start() - XCTAssertEqual(model.phase, .pairIntro) - - model.startPairing() - guard case .scanning = model.phase else { - return XCTFail("expected scanning, got \(model.phase)") - } - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - XCTAssertEqual( - LaunchFlowModel.DiscoveredDevice(name: "Trailhead").advertisedName, - "OBC-Trailhead" - ) - - model.confirmPairing() - XCTAssertEqual(model.phase, .pairing) - try await waitFor("paired", timeout: .seconds(5)) { model.phase == .paired(deviceName: "Trailhead") } - XCTAssertTrue(control.bonded, "pairing success must record the bond") + @Test func failedNameCanRetryOrKeepFactoryName() async throws { + let (model, control) = make() + try await pair(model) + control.failNextOp(.writeFailed) + model.deviceNameDraft = "New name" + model.saveNameAndContinue() + try await wait { model.nameSaveError != nil } + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(control.bondedName == "Trailhead") + #expect(control.fixtures.config.name == "Trailhead") + model.saveNameAndContinue() + try await wait { model.phase == .setup } + #expect(control.bondedName == "New name") + } + @Test func emptyNameDoesNotAdvanceAndKeepingNameMakesNoWrite() async throws { + let (model, control) = make() + try await pair(model) + model.deviceNameDraft = " " + model.saveNameAndContinue() + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(!model.canSaveName) + control.failNextOp(.writeFailed) model.finishPairing() - XCTAssertEqual(model.phase, .main) + #expect(model.phase == .setup) + #expect(control.bondedName == "Trailhead") } - func testPairingTimeoutShowsD5AndRetryLoopsToScanning() async throws { - let (model, _) = makeModel(.pairingTimeout) - model.start() - model.startPairing() - try await waitFor("D5 timeout", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } + @Test func unicodeNameFitsDeviceByteLimit() { + let normalized = DeviceRenaming.normalized(" " + String(repeating: "🚲", count: 30) + " ") + #expect(normalized.utf8.count <= DeviceConfig.maxNameUTF8Bytes) + #expect(!normalized.isEmpty) + #expect(normalized.allSatisfy { $0 == "🚲" }) + } + @Test func emptyScanAndRetryUseTimeoutRecovery() async throws { + let (model, control) = make() + control.pairingDevices = [] + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == .pairFailed(.timeout) } + control.pairingDevices = nil model.retryPairing() - guard case .scanning = model.phase else { - return XCTFail("retry must loop back to scanning, got \(model.phase)") - } - try await waitFor("D5 again", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } + try await wait { model.phase == .paired(deviceName: "Trailhead") } } - /// A declined passkey is a gated failure: it surfaces on the row tap, not during the scan. - func testPairingRejectedShowsD5RejectedVariant() async throws { - let (model, _) = makeModel(.pairingRejected) - model.start() - model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) + @Test func radioAndPairingFailuresStayActionable() async throws { + for (scenario, expected) in [ + (Scenario.bluetoothOff, LaunchFlowModel.Phase.radioBlocked(.off)), + (.permissionDenied, .radioBlocked(.denied)), + (.pairingRejected, .pairFailed(.rejected)) + ] { + let (model, _) = make(scenario) + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == expected } + model.browseLibrary() + #expect(model.phase == .main) } - model.confirmPairing() - XCTAssertEqual(model.phase, .pairing) - try await waitFor("D5 rejected", timeout: .seconds(5)) { model.phase == .pairFailed(.rejected) } } - /// An already-bonded refusal is indistinguishable on the wire from a declined passkey: the - /// device drops the link and CoreBluetooth surfaces a generic `DeviceError.pairingFailed`. It - /// lands on the same `.pairFailed(.rejected)` screen, whose copy covers both. - func testGenericPairingFailureLandsOnRejectedForBondedCase() async throws { - let (model, _) = makeModel(.pairingRejected) - model.start() + @Test func scanTimeoutAndCancellationDoNotLeaveStaleWork() async throws { + let (model, control) = make(timing: .init(scanTimeout: .milliseconds(30), pairingBeat: .zero)) + control.latency = .seconds(30) model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - model.confirmPairing() - try await waitFor("generic pairing failure → rejected", timeout: .seconds(5)) { - model.phase == .pairFailed(.rejected) - } + model.allowBluetooth() + try await wait { model.phase == .pairFailed(.timeout) } + model.retryPairing() + model.cancelScanning() + #expect(model.phase == .pairIntro) + control.latency = .zero + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == .paired(deviceName: "Trailhead") } + #expect(control.connection == .connected) } - // MARK: The rejected-pairing copy - - /// The `.rejected` copy must offer the code retry and name the already-paired possibility - /// with its recovery, without asserting which failure happened. - func testRejectedCopyCoversBothPasskeyAndAlreadyBonded() { - let reason = LaunchFlowModel.PairingFailure.rejected.reason - - XCTAssertTrue( - reason.contains("If the code was wrong") && reason.contains("If the OBC is already paired to another phone"), - "must present both as possibilities, not assert one" - ) - - XCTAssertTrue( - reason.contains("Forget phone"), - "must point at Forget phone as the re-pair recovery" - ) - - XCTAssertEqual(LaunchFlowModel.PairingFailure.rejected.title, "Pairing didn't finish") + @Test func cancellingAuthenticationCannotAdvanceOrReopenLink() async throws { + let (model, control) = make() + control.latency = .milliseconds(80) + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == .pairing } + model.cancelScanning() + try await Task.sleep(for: .milliseconds(350)) + #expect(model.phase == .pairIntro) + #expect(control.connection == .disconnected) + #expect(!control.bonded) } - func testTimeoutCopyUnchanged() { - let timeout = LaunchFlowModel.PairingFailure.timeout - XCTAssertEqual(timeout.title, "Couldn't find your OBC") - XCTAssertTrue(timeout.reason.contains("scanned for 30 seconds")) - XCTAssertFalse(timeout.reason.contains("Forget phone")) + @Test func incompatibleDeviceStillReachesOptionalSetup() async throws { + let (model, control) = make() + control.deviceInfo = DeviceInfo(name: "Trailhead", firmwareVersion: "0.1.0", protocolVersion: 3) + try await pair(model) + model.finishPairing() + #expect(model.phase == .setup) } - func testScanWindowExpiryIsATimeout() async throws { - let (model, control) = makeModel( - .noDevice, - timing: .init(connectGrace: .seconds(2), scanTimeout: .milliseconds(50), pairingBeat: .zero) - ) - control.latency = .seconds(30) // the mock never "finds" the device in time - model.start() - model.startPairing() - try await waitFor("scan-window timeout", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } + @Test func bondedLaunchResumesOnlyPendingSetup() { + let (normal, _) = make(.happyPath) + normal.start() + #expect(normal.phase == .main) + let (pending, control) = make(.deviceUnreachable, pending: { true }) + control.latency = .seconds(3_600) + pending.start() + #expect(pending.phase == .setup) + pending.finishSetup() + pending.replaySetup() + #expect(pending.phase == .setup) } - func testBluetoothOffShowsH8AndLibraryStaysReachable() async throws { - let (model, _) = makeModel(.bluetoothOff) + @Test(arguments: [ConnectionState.disconnected, .connecting, .connected, .outOfRange]) + func bondedLaunchShowsLibraryBeforeAnyLinkWork(_ connection: ConnectionState) { + let (model, control) = make(.happyPath) + control.connection = connection + control.latency = .seconds(3_600) model.start() - model.startPairing() - try await waitFor("H8", timeout: .seconds(5)) { model.phase == .radioBlocked(.off) } - - model.browseLibrary() - XCTAssertEqual(model.phase, .main) + #expect(model.phase == .main) + #expect(control.connection == connection) + #expect(control.bonded) } - func testPermissionDeniedShowsH7State() async throws { - let (model, _) = makeModel(.permissionDenied) + @Test func backgroundReconnectNeverChangesLibraryNavigation() async throws { + let (model, control) = make(.happyPath) + control.connection = .disconnected + let states = MockTransport(control: control).state + var observed: [ConnectionState] = [] + let watch = Task { for await state in states { observed.append(state) } } + defer { watch.cancel() } model.start() - model.startPairing() - try await waitFor("H7", timeout: .seconds(5)) { model.phase == .radioBlocked(.denied) } + #expect(model.phase == .main) + try await wait { observed.count == 3 } + #expect(observed == [.disconnected, .connecting, .connected]) + #expect(model.phase == .main) } - func testCancelScanningStepsBackAndDropsTheLink() async throws { - let (model, control) = makeModel(.noDevice) - control.latency = .milliseconds(200) + @Test func failedBackgroundReconnectKeepsLibraryAndBond() async throws { + let (model, control) = make(.happyPath) + control.connection = .disconnected + control.failNextOp(.readFailed) + let states = MockTransport(control: control).state + var observed: [ConnectionState] = [] + let watch = Task { for await state in states { observed.append(state) } } + defer { watch.cancel() } model.start() - model.startPairing() - model.cancelScanning() - XCTAssertEqual(model.phase, .pairIntro) - try await waitFor("link down", timeout: .seconds(5)) { control.connection == .disconnected } + #expect(model.phase == .main) + try await wait { observed.count == 3 } + #expect(observed == [.disconnected, .connecting, .disconnected]) + #expect(model.phase == .main) + #expect(control.bonded) } - func testPairingHelpReturnsToTheIntroSteps() async throws { - let (model, _) = makeModel(.pairingRejected) + @Test func forgetReturnsToWelcome() async throws { + let (model, _) = make(.happyPath) model.start() - model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - model.confirmPairing() // the decline lands on the gated row tap - try await waitFor("D5", timeout: .seconds(5)) { model.phase == .pairFailed(.rejected) } - - model.showPairingHelp() - XCTAssertEqual(model.phase, .pairIntro) + try await wait { model.phase == .main } + model.forgetDevice() + #expect(model.phase == .welcome) } } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift index c955e98d5..2c3414c3b 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift @@ -162,6 +162,99 @@ final class MainScreenModelTests: XCTestCase { XCTAssertEqual(model.routes.count, 5) } + func testOfflineLibraryDefersDeviceReadsUntilConnected() async throws { + for connection in [ConnectionState.disconnected, .connecting, .outOfRange] { + for savedContent in [false, true] { + let control = MockControl(scenario: .happyPath) + control.latency = .zero + control.connection = connection + let library = InMemoryLibraryStore() + if savedContent { + library.savePlannedRoute(importedRecord()) + library.saveRide(Ride( + summary: RideSummary(id: RideID("saved"), name: "Saved ride", date: Date(), distanceMeters: 1_000), + points: [])) + } + let transport = ObservedMockTransport(control: control) + let model = MainScreenModel(transport: transport, library: library) + model.start() + try await waitFor("offline Library") { + model.connection == connection && model.loadState == .loaded && transport.catalogChangesObserved + } + XCTAssertEqual(model.routes.count, savedContent ? 1 : 0) + XCTAssertEqual(model.rides.count, savedContent ? 1 : 0) + model.reload() + control.deviceDeletesRoute(DeviceObjectID(7)) + let stayedLocal = await neverHolds({ + transport.routeCatalogStartedCount != 0 || transport.tripCatalogStartedCount != 0 + || transport.deviceInfoStartedCount != 0 || model.loadState != .loaded + }, for: .milliseconds(50)) + XCTAssertTrue(stayedLocal, "offline retries and catalog events must not read the device") + XCTAssertTrue(control.setClockSamples.isEmpty) + + control.connection = .connected + try await waitFor("connected catalogs and identity") { + model.connectedScope != nil && model.loadState == .loaded + } + XCTAssertEqual(transport.routeCatalogStartedCount, 1) + XCTAssertEqual(transport.tripCatalogStartedCount, 1) + XCTAssertEqual(transport.deviceInfoStartedCount, 1) + } + } + } + + func testDisconnectDrainsAnOpenCatalogWithoutAReadError() async throws { + let control = MockControl(scenario: .happyPath) + control.latency = .zero + let transport = ObservedMockTransport(control: control, gateFirstRouteCatalog: true) + let model = MainScreenModel(transport: transport) + model.start() + defer { transport.releaseFirstRouteCatalog() } + try await waitFor("open catalog") { transport.routeCatalogStartedCount == 1 } + control.connection = .disconnected + try await waitFor("offline Library") { model.connection == .disconnected && model.loadState == .loaded } + model.reload() + transport.releaseFirstRouteCatalog() + try await waitFor("catalog drained") { transport.routeCatalogCompletedCount == 1 } + let stayedLocal = await neverHolds({ + model.loadState != .loaded || transport.tripCatalogStartedCount != 0 + || transport.deviceInfoStartedCount != 0 + }, for: .milliseconds(50)) + XCTAssertTrue(stayedLocal) + XCTAssertEqual(control.cancelledRouteCatalogReadCount, 0) + + control.connection = .connected + try await waitFor("reconnected Library") { model.connectedScope != nil && model.loadState == .loaded } + XCTAssertEqual(transport.routeCatalogStartedCount, 2) + XCTAssertEqual(transport.tripCatalogStartedCount, 1) + XCTAssertEqual(transport.deviceInfoStartedCount, 1) + } + + func testFailedReconnectCatalogCannotDisproveAnotherDevicesSavedLink() async throws { + let library = InMemoryLibraryStore() + var record = importedRecord() + record.deviceLink = DeviceRouteLink( + serial: "OBC-B", storeID: FixtureSet.defaultStoreID, objectID: DeviceObjectID(900)) + record.uploadedCRC32 = 0x1234_5678 + library.savePlannedRoute(record) + let (model, control) = makeModel(.happyPath, library: library, seedLibrary: false) + try await startLoaded(model) + try await waitFor("first identity") { model.connectedScope != nil } + + control.connection = .disconnected + try await waitFor("link down") { model.connection == .disconnected } + control.deviceInfo = DeviceInfo( + name: "Second OBC", firmwareVersion: "1.0.0", serial: "OBC-B", storeID: FixtureSet.defaultStoreID) + control.failNextOp(.readFailed) + control.connection = .connected + try await waitFor("new identity after failed catalog") { + model.connectedScope?.serial == "OBC-B" && model.loadState == .failed + } + XCTAssertEqual(library.plannedRoutes().first?.deviceLink, record.deviceLink) + XCTAssertEqual(library.plannedRoutes().first?.uploadedCRC32, record.uploadedCRC32) + XCTAssertFalse(model.isUploaded(record.id)) + } + // MARK: Search func testSearchFiltersBothTabsCaseInsensitively() async throws { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift new file mode 100644 index 000000000..2d8f6e373 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import OBCUI + +@Suite @MainActor struct OnboardingProgressTests { + @Test func interruptedSetupResumesAndCompletionDoesNotReplay() throws { + let name = "OnboardingProgressTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let first = OnboardingProgress(defaults: defaults) + #expect(!first.isPending) + first.begin() + first.move(to: .route) + + let resumed = OnboardingProgress(defaults: defaults) + #expect(resumed.isPending) + #expect(resumed.stage == .route) + resumed.begin() + #expect(resumed.stage == .route) + resumed.finish() + + let finished = OnboardingProgress(defaults: defaults) + #expect(!finished.isPending) + #expect(finished.showsReadyNote) + finished.dismissReadyNote() + #expect(!OnboardingProgress(defaults: defaults).showsReadyNote) + finished.replay() + #expect(finished.stage == .sensors) + #expect(finished.isPending) + finished.reset() + #expect(!OnboardingProgress(defaults: defaults).isPending) + } +} diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift index 4298b5a09..b45a94473 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift @@ -339,6 +339,7 @@ final class RideSyncCoordinatorTests: XCTestCase { XCTAssertFalse(try XCTUnwrap(library.ridePoints(first.summary.id)).isEmpty) XCTAssertNil(coordinator.lastSyncCount, "transport completion cannot override a local failure") XCTAssertEqual(coordinator.syncInterruption?.landed, 1) + XCTAssertEqual(coordinator.syncInterruption?.reason, malformedPayload ? .download : .archive) XCTAssertFalse(coordinator.upToDateToastVisible) if !malformedPayload { try FileManager.default.removeItem(at: blocker) } @@ -368,9 +369,8 @@ final class RideSyncCoordinatorTests: XCTestCase { for revision: UInt64 in [1, 2] { let source = RideSource(storeID: scope.storeID, objectID: 42, revision: revision, payloadLength: UInt64(payload.count), payloadCRC32: CRC32.checksum(payload)) - var catalogSummary = original.summary - catalogSummary.name = "Stale catalog display name" - catalogSummary.source = source + let catalogSummary = RideSummary(id: id, name: "Stale catalog display name", + date: .distantPast, distanceMeters: 0, source: source) library.markRideSynced(id) let transport = ScriptedDownloadTransport( base: MockTransport(control: control), @@ -382,6 +382,8 @@ final class RideSyncCoordinatorTests: XCTestCase { try await waitFor("exact revision archived") { coordinator.syncState == .done } XCTAssertEqual(library.archivedRideSource(id), source) XCTAssertEqual(library.rideSummaries().first?.name, original.summary.name) + XCTAssertEqual(library.rideSummaries().first?.date, original.summary.date) + XCTAssertEqual(library.rideSummaries().first?.distanceMeters, original.summary.distanceMeters) let next = RideSyncCoordinator(transport: transport, library: library, timing: Self.stickyTiming) try await startConnected(next) next.sync() diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift index dc72538bf..25b7b7950 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift @@ -14,6 +14,8 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev private let lock = NSLock() private var routeCatalogStarted = 0 private var routeCatalogCompleted = 0 + private var tripCatalogStarted = 0 + private var deviceInfoStarted = 0 private var observedCatalogChanges = false init(control: MockControl, gateFirstRouteCatalog: Bool = false) { @@ -23,6 +25,8 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev var routeCatalogStartedCount: Int { lock.withLock { routeCatalogStarted } } var routeCatalogCompletedCount: Int { lock.withLock { routeCatalogCompleted } } + var tripCatalogStartedCount: Int { lock.withLock { tripCatalogStarted } } + var deviceInfoStartedCount: Int { lock.withLock { deviceInfoStarted } } var catalogChangesObserved: Bool { lock.withLock { observedCatalogChanges } } func releaseFirstRouteCatalog() { @@ -38,7 +42,10 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev func connect() async throws { try await base.connect() } func disconnect() async { await base.disconnect() } - func deviceInfo() async throws -> DeviceInfo { try await base.deviceInfo() } + func deviceInfo() async throws -> DeviceInfo { + lock.withLock { deviceInfoStarted += 1 } + return try await base.deviceInfo() + } func setClock(_ sample: WallClockSample) async throws -> ClockSyncOutcome { try await base.setClock(sample) } @@ -61,7 +68,10 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev func uploadRoute(_ route: RouteBlob) -> TransferHandle { base.uploadRoute(route) } func deleteRoute(_ id: DeviceObjectID) async throws { try await base.deleteRoute(id) } - func listTrips() async throws -> [TripCatalogEntry] { try await base.listTrips() } + func listTrips() async throws -> [TripCatalogEntry] { + lock.withLock { tripCatalogStarted += 1 } + return try await base.listTrips() + } func downloadTrip(_ id: DeviceObjectID) async throws -> TripObjectCodec.Trip { try await base.downloadTrip(id) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift index a290e8609..b5999be47 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift @@ -147,6 +147,11 @@ struct TripReconcileModelTests { #expect(control.deviceTripCount == 0, "no trip object while the reversed days land") upload.cancel() + control.connection = .connected + try await waitFor("reconnected before retry", timeout: .seconds(20), interval: .milliseconds(5)) { + model.connection == .connected && model.connectedScope != nil + } + let retry = await model.prepareTripUpload(tripID, timing: Self.fastTiming)! retry.start() try await waitFor("retry landed", timeout: .seconds(20), interval: .milliseconds(5)) { retry.phase == .done } @@ -178,6 +183,41 @@ struct TripReconcileModelTests { // MARK: Reconcile transitions + @Test + func aReplacementDevicesCatalogKeepsThePreviousDevicesRouteTripAndDayLinks() async throws { + let (model, control, library) = try await makeMainWithLibrary() + try await uploadTrip(model) + let kettle = RouteID("kettle-moraine-loop") + let savedRoute = try #require(library.plannedRoutes().first { $0.id == kettle }) + let savedTrip = try #require(library.trips().first { $0.id == tripID }) + #expect(savedRoute.deviceLink != nil) + #expect(savedTrip.deviceLink != nil) + #expect(savedTrip.dayCopies.compactMap { $0 }.count == savedTrip.dayCount) + + control.connection = .disconnected + try await waitFor("link down") { model.connection == .disconnected } + for id in control.deviceTripObjectIDs { control.deviceDeletesTrip(id) } + control.fixtures.routes = [] + control.deviceInfo = DeviceInfo( + name: "Second OBC", firmwareVersion: "1.0.0", serial: "OBC-B", + storeID: FixtureSet.defaultStoreID) + control.connection = .connected + try await waitFor("replacement identity and catalogs") { + model.connectedScope?.serial == "OBC-B" && model.loadState == .loaded + } + + let keptRoute = try #require(library.plannedRoutes().first { $0.id == kettle }) + let keptTrip = try #require(library.trips().first { $0.id == tripID }) + #expect(keptRoute.deviceLink == savedRoute.deviceLink) + #expect(keptRoute.uploadedCRC32 == savedRoute.uploadedCRC32) + #expect(keptTrip.deviceLink == savedTrip.deviceLink) + #expect(keptTrip.uploadedCRC32 == savedTrip.uploadedCRC32) + #expect(keptTrip.dayCopies == savedTrip.dayCopies) + #expect(model.plannedDeviceObjectID(for: kettle) == nil) + #expect(model.tripOnDeviceState(tripID) == .notOnDevice) + #expect(model.planTripUpload(tripID)?.days.allSatisfy { $0.action == .fresh } == true) + } + @Test func aDeviceSideTripDeleteClearsTheLink() async throws { let (model, control) = try await makeMain() diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift index 13189ddfd..c6e82ec4e 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift @@ -61,6 +61,7 @@ struct TripUploadModelTests { startAndConfirm(upload) try await waitFor("interrupted", timeout: .seconds(20), interval: .milliseconds(5)) { upload.phase == .interrupted } + control.latency = .milliseconds(100) upload.resume() try await waitFor("done after resume", timeout: .seconds(20), interval: .milliseconds(5)) { upload.phase == .done } #expect(upload.committedCount == 3) @@ -69,6 +70,23 @@ struct TripUploadModelTests { // MARK: Flat catalog vs. resident menu capacity + @Test + func anUnavailableRequiredTransferFailsInsteadOfReportingDone() async throws { + let (model, control) = try await makeMain() + let plan = try #require(model.planTripUpload(tripID)) + let upload = TripUploadModel( + transport: MockTransport(control: control), + card: DeviceTripCard(name: "Trip", days: []), deviceName: "OBC", + precheck: plan.precheck, + steps: [.transfer(title: "Trip details", makeTransfer: { nil }, commit: { _, _ in + Issue.record("an unavailable transfer cannot commit") + })], timing: Self.fastTiming) + upload.start() + try await waitFor("required transfer fails", timeout: .seconds(20)) { upload.phase == .failed } + #expect(upload.committedCount == 0) + #expect(control.deviceTripCount == 0) + } + @Test func aFullResidentRouteMenuDoesNotPretendTheFlatStoreIsFull() async throws { let (model, control) = try await makeMain(routesNearlyFull: true) diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift new file mode 100644 index 000000000..ece2641f2 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +import OBCDomain +import OBCTransport +@testable import OBCUI + +@MainActor +struct UpdateSurfaceModelTests { + private let release = FirmwareRelease( + version: "1.4.0", bytes: 10, sha256: String(repeating: "a", count: 64), + url: URL(string: "https://updates.openbikecomputer.com/fw/UPDATE.BIN")! + ) + + @Test func setupSuppressesOffersWithoutAnsweringThem() async throws { + let store = InMemoryUpdateSurfaceStore() + let notifier = SurfaceNotifier() + let model = UpdateSurfaceModel( + transport: SurfaceLink(), bondStore: SurfaceBondStore(), + runner: UpdateSurfaceRunner( + checker: UpdateChecker(store: InMemoryUpdateCheckStore( + record: UpdateCheckRecord(release: release, checkedAt: Date()) + )), store: store + ), notifier: notifier + ) + model.setEnabled(false) + model.start() + model.appBecameActive() + #expect(await neverHolds({ model.pending != nil }, for: .milliseconds(50))) + #expect(notifier.requests == 0) + #expect(!store.loadDidAskNotificationPermission()) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("offer after setup") { model.pending != nil } + model.setEnabled(false) + #expect(model.pending == nil) + #expect(store.loadAnsweredVersion(device: "OBC-001") == nil) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("offer remains unanswered") { model.pending != nil } + model.dismiss() + #expect(store.loadAnsweredVersion(device: "OBC-001") == "1.4.0") + } + + @Test func aCheckThatFinishesDuringSetupCannotOfferOrRequestPermission() async throws { + let fetcher = HeldSurfaceFetcher() + let store = InMemoryUpdateSurfaceStore() + let cache = InMemoryUpdateCheckStore() + let notifier = SurfaceNotifier() + let model = UpdateSurfaceModel( + transport: SurfaceLink(), bondStore: SurfaceBondStore(), + runner: UpdateSurfaceRunner( + checker: UpdateChecker(fetcher: fetcher, store: cache), store: store + ), notifier: notifier + ) + model.appBecameActive() + try await waitFor("manifest request") { fetcher.started.current == true } + model.setEnabled(false) + fetcher.response.fulfill((200, try JSONEncoder().encode(release))) + try await waitFor("late check completion") { cache.loadCheck() != nil } + #expect(await neverHolds({ model.pending != nil }, for: .milliseconds(50))) + #expect(notifier.requests == 0) + #expect(!store.loadDidAskNotificationPermission()) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("new check after setup") { model.pending != nil } + try await waitFor("permission request") { notifier.requests == 1 } + #expect(model.pending?.release == release) + } +} + +private struct SurfaceLink: DeviceLink { + var state: AsyncStream { + AsyncStream { $0.yield(.connected); $0.finish() } + } + func connect() async throws {} + func disconnect() async {} + func deviceInfo() async throws -> DeviceInfo { + DeviceInfo(name: "Trailhead", firmwareVersion: "1.3.0", serial: "OBC-001") + } +} + +private struct SurfaceBondStore: BondStore { + func load() -> BondRecord? { BondRecord(deviceName: "Trailhead") } + func save(_ record: BondRecord) {} + func clear() {} +} + +private final class SurfaceNotifier: UpdateNotifying, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var requests: Int { lock.withLock { count } } + func requestAuthorization() async { lock.withLock { count += 1 } } + func notifyUpdateAvailable(version: String, deviceName: String) async -> Bool { false } +} + +private struct HeldSurfaceFetcher: ManifestFetching { + let started = AsyncPromise() + let response = AsyncPromise<(Int, Data)>() + func get(_ url: URL) async throws -> (status: Int, body: Data) { + started.fulfill(true) + return await response.value + } +} diff --git a/companion-ios/project.yml b/companion-ios/project.yml index 3bd7782b1..32cd8275f 100644 --- a/companion-ios/project.yml +++ b/companion-ios/project.yml @@ -79,6 +79,11 @@ targets: # is real (grep a Release build: the marker string is absent). - package: OBCKit product: OBCMock + entitlements: + path: OBCCompanion/OBCCompanion.entitlements + properties: + com.apple.developer.associated-domains: + - applinks:openbikecomputer.com info: path: OBCCompanion/Info.plist properties: diff --git a/companion-ios/scripts/generate-website-fixture.py b/companion-ios/scripts/generate-website-fixture.py index 10c1a8c2b..cabc8df97 100755 --- a/companion-ios/scripts/generate-website-fixture.py +++ b/companion-ios/scripts/generate-website-fixture.py @@ -27,7 +27,7 @@ DISPLAY_NAME = "Grimsel Pass" FIXTURE_DIR = REPO_DIR / "companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures" FIXTURE_JSON = FIXTURE_DIR / "website.json" -IMPORT_GPX = FIXTURE_DIR / "website-import.gpx" +IMPORT_GPX = REPO_DIR / "companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx" GPX_NS = "http://www.topografix.com/GPX/1/1" diff --git a/docs/assets/companion/route-imported.webp b/docs/assets/companion/route-imported.webp index e258276d9..ab4a189dd 100644 Binary files a/docs/assets/companion/route-imported.webp and b/docs/assets/companion/route-imported.webp differ diff --git a/docs/content/software/companion-link.md b/docs/content/software/companion-link.md index 58165ddf1..609950df6 100644 --- a/docs/content/software/companion-link.md +++ b/docs/content/software/companion-link.md @@ -314,6 +314,9 @@ then removes the host keys, because the order decides what a failure leaves behi cleanup has no receipt, so the screen asks for a restart to finish. A disconnect proves nothing about stored keys. A factory reset runs the same removal, so the setup that follows can pair a phone again. +It also deletes personal card data. Maps stay installed, so setup does not require another map +download. Reset waits for card deletion to finish. A card failure keeps the reset screen open for +Retry. Device information, the battery level, and the protocol version are readable before pairing. Everything else needs an authenticated, encrypted link. The clock is set by the phone or by a GPS diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index d0445bb42..57a17dbc4 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -332,13 +332,15 @@ all_added_sub = "Erst eins entfernen" # --- Factory-reset settings screen --- [reset] +erasing = "Persönliche Daten löschen..." +data_failed = "Daten nicht gelöscht. Speicherkarte prüfen und erneut versuchen." retry = "Wiederholen" power_cycle = "Ausschalten und den OBC wieder einschalten." phone_failed = "Telefon nicht entfernt" title = "ZURÜCKSETZEN" factory = "Werksreset" -erases = "Löscht alle Werte" -saved_time = "& die Uhrzeit." +erases = "Löscht Einstellungen, Routen, Touren und Fahrten." +saved_time = "Karten bleiben erhalten." confirm = "Bestätigen" keep_holding = "Weiter halten" hold_to_erase = "Halten: löschen" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index a59f8ece2..373f7bf2b 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -360,10 +360,12 @@ all_added_sub = "Remove one to swap" # --- Factory-reset settings screen --- [reset] +erasing = "Erasing personal data..." +data_failed = "Data not erased. Check the memory card and retry." title = "RESET" factory = "Factory reset" -erases = "Erases all settings" -saved_time = "& saved time." +erases = "Erases settings, routes, trips, and rides." +saved_time = "Keeps maps." confirm = "Confirm" keep_holding = "Keep holding" hold_to_erase = "Hold to erase" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 847024b52..8ad75affd 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -331,13 +331,15 @@ all_added_sub = "Quita uno antes" # --- Factory-reset settings screen --- [reset] +erasing = "Borrando datos personales..." +data_failed = "Datos no borrados. Comprueba la tarjeta y reintenta." retry = "Reintentar" power_cycle = "Apaga y vuelve a encender el OBC." phone_failed = "Teléfono no eliminado" title = "RESTABLECER" factory = "Restablecer" -erases = "Borra los ajustes" -saved_time = "y la hora." +erases = "Borra ajustes, rutas, viajes y recorridos." +saved_time = "Conserva los mapas." confirm = "Confirmar" keep_holding = "Sigue pulsando" hold_to_erase = "Mantén: borrar" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index a2744166e..af1954eea 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -331,13 +331,15 @@ all_added_sub = "Retirez-en un" # --- Factory-reset settings screen --- [reset] +erasing = "Effacement des données..." +data_failed = "Données non effacées. Vérifiez la carte mémoire et réessayez." retry = "Réessayer" power_cycle = "Éteignez puis rallumez l'OBC." phone_failed = "Téléphone non supprimé" title = "RÉINITIALISER" factory = "Réinit. usine" -erases = "Efface les réglages" -saved_time = "& l'heure." +erases = "Efface réglages, itinéraires, voyages et sorties." +saved_time = "Conserve les cartes." confirm = "Confirmer" keep_holding = "Maintenez" hold_to_erase = "Tenez: effacer" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4030f540e..de4c60a38 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1905,26 +1905,46 @@ impl App { self.ui.map_dirty = true; } - /// Commit a confirmed factory reset only after the bond store acknowledges removal. The + /// Commit a confirmed factory reset after bond removal and personal-data deletion. The /// board's unconfirmed controller clearance keeps the restart instruction on screen. pub(crate) fn finish_factory_reset(&mut self) { use crate::ble::BondStatus; - if !matches!(self.state.bond_status, BondStatus::Removed | BondStatus::RestartRequired) - || !self.factory_reset_pending() - { + if !self.factory_reset_pending() { + return; + } + let bond_cleared = !self.state.bonding + || (!self.state.device.ble_paired + && !matches!(self.state.bond_status, BondStatus::Pending | BondStatus::Failed(_))) + || matches!(self.state.bond_status, BondStatus::Removed | BondStatus::RestartRequired); + if !bond_cleared { + return; + } + let mut data_cleared = false; + for screen in self.ui.stack.iter_mut() { + if let Screen::Reset(reset) = screen { + reset.request_data_clear(); + data_cleared = reset.data_cleared(); + } + } + if !data_cleared { return; } + for screen in self.ui.stack.iter_mut() { + if let Screen::Reset(reset) = screen { + reset.committed(); + } + } self.settings = Settings::FACTORY; self.wall_clock.set(self.settings.local_clock(), self.ui.now_ms); self.sync_find_preferences(); self.settings_ops.note_edited(); - if self.state.bond_status == BondStatus::Removed { + if self.state.bond_status != BondStatus::RestartRequired { screen::apply(&mut self.ui.stack, screen::setup::go_to(&self.settings)); self.ui.cancel_holds(); } } - fn factory_reset_pending(&self) -> bool { + pub(crate) fn factory_reset_pending(&self) -> bool { self.ui.stack.iter().any(|s| matches!(s, Screen::Reset(r) if r.removing())) } @@ -4622,9 +4642,8 @@ mod tests { /// cannot loop forever. const MAX_DEPTH_BACKOUT: usize = crate::screen::MAX_DEPTH; - /// An unpaired device needs no bond receipt: reset saves at once and opens setup. #[test] - fn an_unpaired_factory_reset_saves_at_once_and_opens_setup() { + fn an_unpaired_factory_reset_waits_for_data_deletion_then_opens_setup() { use crate::screen::{ResetScreen, SettingsPage}; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings::default()); @@ -4632,6 +4651,14 @@ mod tests { let _ = app.ui.stack.push(Screen::Reset(ResetScreen::new())); app.apply_gesture(Gesture::Press); app.apply_gesture(Gesture::Hold); + app.finish_factory_reset(); + assert_eq!(*app.settings(), Settings::default()); + let Some(Screen::Reset(reset)) = app.ui.stack.last_mut() else { + panic!("reset stays visible"); + }; + assert!(reset.data_clear_requested()); + reset.data_clear_finished(true); + app.finish_factory_reset(); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); assert_eq!(*app.settings(), Settings::FACTORY); assert!(!app.state.ble_forget_requested, "there is no phone to forget"); diff --git a/firmware/obc-app/src/catalog_state.rs b/firmware/obc-app/src/catalog_state.rs index ad133a156..d601523e2 100644 --- a/firmware/obc-app/src/catalog_state.rs +++ b/firmware/obc-app/src/catalog_state.rs @@ -548,6 +548,9 @@ use crate::device_core::{CatalogTag, OperationToken, StoreRevision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogIntent { + ClearPersonalData { + store: crate::device_core::StoreIdentity, + }, /// Remove unused temporary directions and abandoned candidates, one commit at a time. RemoveOrphanRoutes, CleanupRoutes { @@ -579,6 +582,10 @@ pub enum CatalogObjectKind { /// One bounded physical catalog operation, carrying the [`OperationToken`] the domain issued. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogEffect { + ClearPersonalData { + token: OperationToken, + store: crate::device_core::StoreIdentity, + }, RemoveOrphanRoutes { token: OperationToken, }, @@ -601,7 +608,8 @@ pub enum CatalogEffect { impl CatalogEffect { pub fn token(&self) -> OperationToken { match self { - CatalogEffect::CleanupRoute { token, .. } + CatalogEffect::ClearPersonalData { token, .. } + | CatalogEffect::CleanupRoute { token, .. } | CatalogEffect::ReadCatalog { token } | CatalogEffect::RemoveObject { token, .. } | CatalogEffect::RemoveOrphanRoutes { token } => *token, @@ -623,6 +631,10 @@ pub enum CatalogError { /// The result of one [`CatalogEffect`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogOutcome { + PersonalDataCleared { + token: OperationToken, + done: bool, + }, OrphanRoutesRemoved { token: OperationToken, }, @@ -655,7 +667,8 @@ pub enum CatalogOutcome { impl CatalogOutcome { pub fn token(&self) -> OperationToken { match self { - CatalogOutcome::CleanupFinished { token } + CatalogOutcome::PersonalDataCleared { token, .. } + | CatalogOutcome::CleanupFinished { token } | CatalogOutcome::CatalogRead { token, .. } | CatalogOutcome::ObjectRemoved { token, .. } | CatalogOutcome::OrphanRoutesRemoved { token } @@ -722,6 +735,10 @@ impl CatalogState { self.cleanup_running } + pub(crate) fn reset_running(&self) -> bool { + matches!(self.pending, Some(CatalogIntent::ClearPersonalData { .. })) + } + pub(crate) fn accepts(&self, outcome: CatalogOutcome) -> bool { self.ops.is_current(outcome.token()) } @@ -752,6 +769,11 @@ impl CatalogState { return Some(CatalogEffect::ReadCatalog { token: self.ops.issue() }); }; let effect = match intent { + CatalogIntent::ClearPersonalData { store } => { + self.cleanup_running = true; + self.pending = Some(intent); + CatalogEffect::ClearPersonalData { token: self.ops.issue(), store } + } CatalogIntent::RemoveOrphanRoutes => CatalogEffect::RemoveOrphanRoutes { token: self.ops.issue() }, CatalogIntent::CleanupRoutes { before_utc, store } => { self.cleanup_running = true; @@ -821,7 +843,12 @@ impl CatalogState { } self.ops.invalidate(); // terminal: a duplicate of this answer is no longer current self.in_flight = false; - if core::mem::take(&mut self.cleanup_running) && !matches!(outcome, CatalogOutcome::ObjectRemoved { .. }) { + if core::mem::take(&mut self.cleanup_running) + && !matches!( + outcome, + CatalogOutcome::ObjectRemoved { .. } | CatalogOutcome::PersonalDataCleared { done: false, .. } + ) + { self.pending = None; } if matches!(outcome, CatalogOutcome::Failed { .. }) @@ -832,6 +859,11 @@ impl CatalogState { self.cascade = None; } match outcome { + CatalogOutcome::PersonalDataCleared { .. } => { + self.loaded_scope = None; + self.refresh_owed = true; + None + } CatalogOutcome::CleanupFinished { .. } => { self.refresh_owed = true; None @@ -1172,7 +1204,9 @@ mod tests { for _ in 0..=steps.capacity() { let Some(effect) = catalogs.next_effect() else { break }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { token, object, .. } => { diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index cf3368ba7..27beab851 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -260,6 +260,23 @@ impl App { self.pass.record(PassStage::Outcomes); if let Some(outcome) = outcomes.catalog.take() { if self.catalogs.accepts(outcome) { + if self.catalogs.reset_running() { + use crate::catalog_state::CatalogOutcome; + let finished = match outcome { + CatalogOutcome::PersonalDataCleared { done: true, .. } => Some(true), + CatalogOutcome::Failed { .. } | CatalogOutcome::Cancelled { .. } => Some(false), + _ => None, + }; + if let Some(success) = finished { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + reset.data_clear_finished(success); + } + } + self.finish_factory_reset(); + self.ui.map_dirty = true; + } + } if self.catalogs.cleanup_running() { for screen in self.ui.stack.iter_mut() { if let crate::screen::Screen::RouteCleanup(screen) = screen { @@ -368,6 +385,11 @@ impl App { if let crate::screen::Screen::RouteCleanup(screen) = screen { screen.cancel(); } + if let crate::Screen::Reset(reset) = screen { + if reset.removing() { + reset.data_clear_finished(false); + } + } } } self.metadata.reset_store(); @@ -464,9 +486,39 @@ impl App { } fn stage_catalog(&mut self, effects: &mut EffectSlots) { self.pass.record(PassStage::Catalog); + self.finish_factory_reset(); + if self.catalogs.can_admit_intent() { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + if reset.data_clear_requested() { + if let Some(scope) = self.pass.store { + self.catalogs + .admit_intent(CatalogIntent::ClearPersonalData { store: scope.store }) + .unwrap(); + reset.data_clear_started(); + self.navigator.forget_personal_data(); + self.metadata = crate::metadata::MetadataMachine::new(); + } else { + reset.data_clear_finished(false); + } + self.ui.map_dirty = true; + } + } + } + } if self.pass.connections.ride_finalized.take().is_some() { self.catalogs.note_ride_finalized(); } + if self.catalogs.remount_required { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + if reset.data_clear_requested() { + reset.data_clear_finished(false); + self.ui.map_dirty = true; + } + } + } + } // A refused intent goes back into the slot it came from: that slot is its producer's // pending state until the catalog has room, so a busy pass costs a delay, not a delete. if let Some(intent) = self.pass.connections.ui_catalog.take() { @@ -501,6 +553,9 @@ impl App { } fn stage_metadata(&mut self, effects: &mut EffectSlots) { + if self.factory_reset_pending() { + return; + } let Some(scope) = self.catalogs.loaded_scope.filter(|_| effects.metadata.is_empty()) else { return; }; @@ -793,6 +848,7 @@ mod tests { assert!(app.ui.stack.push(Screen::Reset(ResetScreen::new())).is_ok()); let mut outcomes = OutcomeSlots::new(); let mut facts = ExternalFacts::NONE; + app.pass.store = Some(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(1) }); let mut plan = pass_with(&mut app, 10, &[Gesture::Press, Gesture::Hold], &mut outcomes, &mut facts); let removal = plan.effects.bond.take().expect("the confirmed reset removes the phone first"); assert!(plan.effects.settings.is_empty()); @@ -817,13 +873,38 @@ mod tests { outcomes.bond.try_put(BondOutcome::KeysRemoved { token: retry.token(), controller }).unwrap(); app.set_ble_status(BleStatus::DISCONNECTED); let mut plan = pass_with(&mut app, 60_030, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "bond removal is not card deletion"); + let deletion = plan.effects.catalog.take().expect("personal card data is next"); + assert!(matches!(deletion, CatalogEffect::ClearPersonalData { .. })); + outcomes + .catalog + .try_put(CatalogOutcome::Failed { token: deletion.token(), error: CatalogError::RemoveFailed }) + .unwrap(); + let plan = pass_with(&mut app, 60_040, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "failed card deletion preserves settings"); + assert!(plan.effects.settings.is_empty()); + assert!(plan.effects.catalog.is_empty(), "failed deletion needs explicit retry"); + let mut plan = pass_with(&mut app, 60_050, &[Gesture::Press], &mut outcomes, &mut facts); + let deletion = plan.effects.catalog.take().expect("Select retries card deletion"); + outcomes + .catalog + .try_put(CatalogOutcome::PersonalDataCleared { token: deletion.token(), done: false }) + .unwrap(); + let mut plan = pass_with(&mut app, 60_060, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "one batch does not finish reset"); + let deletion = plan.effects.catalog.take().expect("deletion continues in bounded batches"); + outcomes + .catalog + .try_put(CatalogOutcome::PersonalDataCleared { token: deletion.token(), done: true }) + .unwrap(); + let mut plan = pass_with(&mut app, 60_070, &[], &mut outcomes, &mut facts); assert_eq!(*app.settings(), Settings::FACTORY); assert!(plan.effects.settings.take().is_some(), "the successful reset saves at once"); match controller { ControllerClearance::Confirmed => assert!(matches!(app.top_screen(), Screen::Hello(_))), ControllerClearance::Unconfirmed => { assert!(matches!(app.top_screen(), Screen::Reset(_)), "restart is required before setup"); - pass_with(&mut app, 60_040, &[Gesture::Press], &mut outcomes, &mut facts); + pass_with(&mut app, 60_080, &[Gesture::Press], &mut outcomes, &mut facts); assert!(matches!(app.top_screen(), Screen::QuickDrawer(d) if d.selection_is_guarded())); } } diff --git a/firmware/obc-app/src/navigator/review.rs b/firmware/obc-app/src/navigator/review.rs index 6429b8934..e9558b12c 100644 --- a/firmware/obc-app/src/navigator/review.rs +++ b/firmware/obc-app/src/navigator/review.rs @@ -487,6 +487,12 @@ impl NavigatorMachine { } } + pub(crate) fn forget_personal_data(&mut self) { + self.reset_detour(); + self.set_active_route(None); + self.review = ReviewState::new(); + } + /// Called only after a complete read and CRC verification on the current card. pub(crate) fn offer_checkpoint(&mut self, store: StoreIdentity, checkpoint: Option) { if self.review.recovery_seen || self.review.change.is_some() { diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 94b8e210a..4a74f8912 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -2,7 +2,7 @@ //! safe alone, so a reset takes two steps: a press to arm, then a hold to erase. A hold on an //! un-armed screen does nothing. A paired device waits for bond removal before it clears the //! settings. Unconfirmed controller clearance asks for a restart before first-use setup. The -//! reset keeps the files on the card. +//! reset keeps maps and deletes personal card data before it clears settings. use embedded_graphics::prelude::Point; use obc_render::{ @@ -15,19 +15,30 @@ use crate::ble::BondStatus; use crate::input::Gesture; use crate::screen::vocab::chrome::{card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; use crate::screen::{palette, Ctx, QuickDrawerScreen, Render, Screen, Transition}; -use crate::settings::Settings; use crate::Msg; +#[derive(Debug, Default, PartialEq, Eq)] +enum DataReset { + #[default] + Waiting, + Requested, + Running, + Failed, + Done, + Committed, +} + /// `armed` is set by the first press, and only an armed screen can erase. #[derive(Debug, Default)] pub struct ResetScreen { armed: bool, removing: bool, + data: DataReset, } impl ResetScreen { pub fn new() -> Self { - ResetScreen { armed: false, removing: false } + Self::default() } /// True while the hold-to-erase bar is on screen and fills with the live hold progress. @@ -39,8 +50,41 @@ impl ResetScreen { self.removing } + pub(crate) fn request_data_clear(&mut self) { + if self.removing && self.data == DataReset::Waiting { + self.data = DataReset::Requested; + } + } + + pub(crate) fn data_clear_requested(&self) -> bool { + self.removing && self.data == DataReset::Requested + } + + pub(crate) fn data_clear_started(&mut self) { + self.data = DataReset::Running; + } + + pub(crate) fn data_clear_finished(&mut self, success: bool) { + self.data = if success { DataReset::Done } else { DataReset::Failed }; + } + + pub(crate) fn data_cleared(&self) -> bool { + self.data == DataReset::Done + } + + pub(crate) fn committed(&mut self) { + self.data = DataReset::Committed; + } + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { if self.removing { + if self.data == DataReset::Failed && g == Gesture::Press { + self.data = DataReset::Requested; + return Transition::None; + } + if matches!(self.data, DataReset::Requested | DataReset::Running | DataReset::Failed) { + return Transition::None; + } return match (g, cx.state.bond_status) { (Gesture::Press, BondStatus::Failed(_)) => { cx.state.ble_forget_requested = true; @@ -60,7 +104,7 @@ impl ResetScreen { } Gesture::Hold if self.armed && cx.state.bond_status == BondStatus::RestartRequired => { self.removing = true; - *cx.settings = Settings::FACTORY; + self.data = DataReset::Requested; Transition::None } // Keep the settings until bond removal succeeds. A failed clear must not boot setup @@ -76,8 +120,9 @@ impl ResetScreen { Transition::None } Gesture::Hold if self.armed => { - *cx.settings = Settings::FACTORY; - crate::screen::setup::go_to(cx.settings) + self.removing = true; + self.data = DataReset::Requested; + Transition::None } Gesture::Back => Transition::Pop, _ => Transition::None, @@ -91,10 +136,16 @@ impl ResetScreen { card_triangle(cv, Point::new(w / 2, TITLE_BAR_H + 50), 24); if self.removing { - let (message, action) = match rx.state.bond_status { - BondStatus::Failed(_) => (Msg::ResetPhoneFailed, Some(Msg::ResetRetry)), - BondStatus::RestartRequired => (Msg::BluetoothRestart, Some(Msg::QuickPower)), - _ => (Msg::BluetoothRemoving, None), + let (message, action) = if self.data == DataReset::Failed { + (Msg::ResetDataFailed, Some(Msg::ResetRetry)) + } else if matches!(self.data, DataReset::Requested | DataReset::Running) { + (Msg::ResetErasing, None) + } else { + match rx.state.bond_status { + BondStatus::Failed(_) => (Msg::ResetPhoneFailed, Some(Msg::ResetRetry)), + BondStatus::RestartRequired => (Msg::BluetoothRestart, Some(Msg::QuickPower)), + _ => (Msg::BluetoothRemoving, None), + } }; let y = wrapped(cv, rx.t(message), w / 2, TITLE_BAR_H + 96, copy_w(w), Font::Body, INK); if rx.state.bond_status == BondStatus::RestartRequired { @@ -140,6 +191,7 @@ mod tests { use super::*; use crate::activity::Activity; use crate::screen::test_ctx; + use crate::settings::Settings; use crate::{AppState, Mode, Units}; fn run(scr: &mut ResetScreen, s: &mut Settings, g: Gesture) -> Transition { @@ -153,7 +205,7 @@ mod tests { } #[test] - fn arm_then_hold_resets_an_unpaired_device_and_starts_setup() { + fn arm_then_hold_waits_for_personal_data_clear_on_an_unpaired_device() { let mut s = Settings { units: Units::Imperial, power_saver: true, fix_interval_s: 30, ..Settings::default() }; let before = s; let mut st = AppState::new(0, 0, 1.0); @@ -166,8 +218,9 @@ mod tests { run_in(&mut scr, &mut s, &mut st, Gesture::Press); assert!(scr.armed); let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); - assert!(matches!(t, Transition::Root(crate::Screen::Hello(_))), "setup opens at once"); - assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); + assert!(matches!(t, Transition::None)); + assert_eq!(s, before, "settings wait for confirmed deletion"); + assert!(scr.data_clear_requested()); assert!(!st.ble_forget_requested, "there is no phone to forget"); } diff --git a/firmware/obc-formats/src/ride.rs b/firmware/obc-formats/src/ride.rs index f90f1d34c..6b04482e8 100644 --- a/firmware/obc-formats/src/ride.rs +++ b/firmware/obc-formats/src/ride.rs @@ -116,6 +116,8 @@ pub struct EffortLimits { /// The fixed summary at the end of every finished ride object. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Footer { + /// Factory sample ride; excluded from the companion app's totals. + pub is_demo: bool, pub start_time: u32, pub distance_m: u32, pub moving_time_s: u32, @@ -159,6 +161,7 @@ impl Footer { max_power: Option, ) -> Footer { Footer { + is_demo: false, start_time, distance_m, moving_time_s, @@ -218,7 +221,7 @@ pub fn encode_footer(footer: &Footer) -> [u8; FOOTER_LEN] { b[30] = footer.avg_hr.unwrap_or(HR_NONE); b[31] = footer.max_hr.unwrap_or(HR_NONE); b[32] = footer.avg_cadence.unwrap_or(CAD_NONE); - // byte 33 is reserved and remains zero, aligning the following u16 values. + b[33] = u8::from(footer.is_demo); b[34..36].copy_from_slice(&footer.avg_power.unwrap_or(PWR_NONE).to_le_bytes()); b[36..38].copy_from_slice(&footer.max_power.unwrap_or(PWR_NONE).to_le_bytes()); b[38..42].copy_from_slice(&footer.energy_kj.unwrap_or(KJ_NONE).to_le_bytes()); @@ -247,7 +250,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { if b[4] != VERSION { return Err(DecodeError::Version); } - if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] != 0 || b[LIMITS_AT + 1] != 0 { + if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] & !1 != 0 || b[LIMITS_AT + 1] != 0 { return Err(DecodeError::Layout); } let name = Name::decode(b[5], &b[NAME_AT..TRIP_AT])?; @@ -262,6 +265,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { } Ok(Footer { + is_demo: b[33] & 1 != 0, start_time: u32::from_le_bytes(b[8..12].try_into().unwrap()), distance_m: u32::from_le_bytes(b[12..16].try_into().unwrap()), moving_time_s: u32::from_le_bytes(b[16..20].try_into().unwrap()), @@ -335,6 +339,7 @@ mod tests { let bytes = encode_footer(&footer); assert_eq!(&bytes[..8], b"OBRF\x06\x0b\x9a\0"); assert_eq!(&bytes[22..30], &[0x2A, 3, 0x80, 2, 3, 0, 0, 0], "climb, descent, point count"); + assert_eq!(bytes[33], 0, "recorded rides are not demos"); assert_eq!(&bytes[34..42], &[210, 0, 224, 1, 0xF4, 2, 0, 0], "power, then energy"); assert_eq!(&bytes[90..102], &[0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 1, 3, 1, 14]); assert_eq!(&bytes[150..], &[185, 0, 250, 0], "max HR, reserved, FTP"); @@ -371,6 +376,7 @@ mod tests { let decoded = decode_footer(footer).unwrap(); assert_eq!(decoded.name(), "Sensor Ride"); assert_eq!(decoded.point_count, 3); + assert!(decoded.is_demo); assert_eq!(decoded.bike, BikeType::Gravel); assert_eq!(decoded.trip(), Some(TRIP)); assert_eq!(decoded.trip_name(), "Alpen Traverse"); @@ -381,9 +387,9 @@ mod tests { #[test] fn footer_rejects_noncanonical_fixed_bytes() { let bytes = encode_footer(&example()); - // Magic, version, length, reserved, name padding, a day past the count, a bike type past + // Magic, version, length, unknown flags, name padding, a day past the count, a bike type past // the four, trip-name padding, and the reserved byte after max HR. - for (offset, value) in [(0, 0), (4, 5), (6, 150), (33, 1), (89, 1), (98, 3), (100, 4), (149, 1), (151, 1)] { + for (offset, value) in [(0, 0), (4, 5), (6, 150), (33, 2), (89, 1), (98, 3), (100, 4), (149, 1), (151, 1)] { let mut bad = bytes; bad[offset] = value; assert!(decode_footer(&bad).is_err(), "offset {offset}"); diff --git a/firmware/obc-fw-nrf54l/Cargo.toml b/firmware/obc-fw-nrf54l/Cargo.toml index f2c82f0a3..8e5d30e1a 100644 --- a/firmware/obc-fw-nrf54l/Cargo.toml +++ b/firmware/obc-fw-nrf54l/Cargo.toml @@ -50,8 +50,8 @@ synth = [] # Seed a Kleine Scheidegg test fix and open the selected map's Peak View at boot. peak-view-demo = [] -# Development-only: add three finished demo rides at boot, before the storage writer starts. -seed-rides = [] +# Factory station only: write the marked demo ride, then halt until normal firmware is flashed. +factory-demo-ride = [] # On-device SD read profiler. Adds relaxed counters at the concrete BlockDevice boundary and one # RTT line per map redraw, splitting the renderer's logical ByteSource requests from the physical diff --git a/firmware/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 6f1a3bbcf..0d937fc11 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -106,11 +106,12 @@ riscv64-elf-gcc`, or the apt package `gcc-riscv64-unknown-elf`, or `RISCV_GCC=

for the build script when a feature is enabled. diff --git a/firmware/obc-fw-nrf54l/src/demo_rides.rs b/firmware/obc-fw-nrf54l/src/demo_rides.rs deleted file mode 100644 index d6026ca27..000000000 --- a/firmware/obc-fw-nrf54l/src/demo_rides.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Development-only finished rides. Called before the board starts its storage writer. - -use obc_crc::Crc32; -use obc_formats::io::{ByteSource, SliceSource}; -use obc_formats::ride::{encode_footer, EffortLimits, Footer, FOOTER_LEN, SAMPLE_LEN}; -use obc_formats::track::{decode_record, encode_record, FLAG_SEGMENT_START}; -use obc_ports::TrackPoint; -use obc_route::RideInfo; -use obc_storage::flat::{ - Allocation, BlockDevice, DisplayName, EntryFlags, EntryMeta, FlatStore, Handle, Mode, Mutation, ObjectId, - ObjectKind, PutSource, Revision, Store, StoreError, -}; - -pub const NAMES: [&str; 3] = ["Demo GPS", "Demo Heart Rate", "Demo Heart Rate + Power"]; -const INTERVALS: u32 = 360; -const STEP_SECONDS: u32 = 5; -const PAYLOAD_LEN: u64 = (INTERVALS as u64 + 1) * SAMPLE_LEN as u64 + FOOTER_LEN as u64; - -// A synthetic loop near Freiburg, in microdegrees and metres. It is not a navigable route. -const LOOP: [(i32, i32, i16); 12] = [ - (7_820_000, 48_000_000, 230), - (7_834_000, 48_008_000, 242), - (7_850_000, 48_009_000, 265), - (7_868_000, 48_005_000, 310), - (7_880_000, 47_998_000, 285), - (7_879_000, 47_990_000, 270), - (7_864_000, 47_987_000, 295), - (7_850_000, 47_985_000, 265), - (7_833_000, 47_987_000, 250), - (7_820_000, 47_990_000, 238), - (7_815_000, 47_994_000, 225), - (7_820_000, 48_000_000, 230), -]; - -fn point(index: u32, sensors: usize) -> TrackPoint { - let scaled = index * (LOOP.len() as u32 - 1); - let segment = (scaled / INTERVALS).min(LOOP.len() as u32 - 2); - let fraction = (scaled - segment * INTERVALS) as i32; - let a = LOOP[segment as usize]; - let b = LOOP[segment as usize + 1]; - let lerp = |a, b| a + (b - a) * fraction / INTERVALS as i32; - let effort = (index % 80).min(80 - index % 80); - TrackPoint { - lon: lerp(a.0, b.0), - lat: lerp(a.1, b.1), - ele: lerp(i32::from(a.2), i32::from(b.2)) as i16, - t_ms: index * STEP_SECONDS * 1000, - segment_start: index == 0, - hr: (sensors >= 1).then_some(120 + effort as u8), - cadence: None, - power: (sensors == 2).then_some(if index % 90 < 5 { 0 } else { 130 + effort as u16 * 3 }), - } -} - -/// Add missing demo names only. Never initialize a card, replace an object, or touch a live ride. -pub fn seed(store: &FlatStore, first_start: u32) -> Result<[ObjectId; 3], StoreError> { - check_store(store)?; - if first_start == 0 || first_start.checked_add(2 * 86_400).is_none() { - return Err(StoreError::Invalid); - } - let mut ids = [ObjectId(0); 3]; - let mut rides = 0; - for entry in store.entries() { - if entry.flags == EntryFlags::RECORDING { - return Err(StoreError::Busy); - } - if entry.kind != ObjectKind::Ride || entry.flags != EntryFlags::NONE { - continue; - } - rides += 1; - if let Some(index) = NAMES.iter().position(|name| Some(*name) == entry.name.as_str()) { - ids[index] = entry.id; - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - upgrade_v5_rides(store)?; - if rides + ids.iter().filter(|id| id.0 == 0).count() > obc_app::MAX_RIDES { - return Err(StoreError::CatalogFull); - } - for (index, id) in ids.iter_mut().enumerate() { - if id.0 == 0 { - *id = write_ride(store, index, first_start + index as u32 * 86_400)?; - } - } - Ok(ids) -} - -/// Add one finished ride from validated bytes. An existing name must hold the same payload. -pub fn seed_file(store: &FlatStore, bytes: &[u8]) -> Result { - check_store(store)?; - let info = RideInfo::read(&SliceSource(bytes)).map_err(|_| StoreError::Invalid)?; - let mut last_time = None; - for sample in bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0 { - let flags = u16::from_le_bytes([sample[10], sample[11]]); - let point = decode_record(sample); - if flags & !FLAG_SEGMENT_START != 0 - || !(-180_000_000..=180_000_000).contains(&point.lon) - || !(-90_000_000..=90_000_000).contains(&point.lat) - || last_time.is_some_and(|time| point.t_ms < time) - { - return Err(StoreError::Invalid); - } - last_time = Some(point.t_ms); - } - let name = DisplayName::new(info.name.as_str()).ok_or(StoreError::Invalid)?; - let mut rides = 0; - let mut existing = None; - for entry in store.entries() { - if entry.flags == EntryFlags::RECORDING { - return Err(StoreError::Busy); - } - if entry.kind == ObjectKind::Ride && entry.flags == EntryFlags::NONE { - rides += 1; - if entry.name == name { - if existing.is_some() { - return Err(StoreError::Invalid); - } - existing = Some(entry); - } - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - if let Some(entry) = existing { - // A v5 copy of this fixture is replaced in place; any other different payload is refused. - if !payload_matches(store, entry.id, bytes)? { - if !is_v5(store, &entry)? { - return Err(StoreError::Invalid); - } - replace(store, &entry, |allocation| store.write(allocation, bytes).map(|_| ()), bytes.len() as u64)?; - } - upgrade_v5_rides(store)?; - return if payload_matches(store, entry.id, bytes)? { Ok(entry.id) } else { Err(StoreError::Media) }; - } - upgrade_v5_rides(store)?; - if rides >= obc_app::MAX_RIDES { - return Err(StoreError::CatalogFull); - } - - let mut allocation = store.allocate(bytes.len() as u64)?; - for chunk in bytes.chunks(512) { - store.write(&mut allocation, chunk)?; - } - let id = store.next_object_id(); - store.commit(&[Mutation::Put { - meta: EntryMeta { - id, - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: bytes.len() as u64, - payload_crc: obc_crc::crc32(bytes), - name, - added_at_utc: info.start_time, - }, - source: PutSource::Fresh(allocation), - }])?; - if !payload_matches(store, id, bytes)? { - return Err(StoreError::Media); - } - Ok(id) -} - -/// The footer length of the previous ride-object version. -const V5_FOOTER_LEN: usize = 150; - -/// Rewrite every finished v5 ride on the card as a v6 ride with no effort limits: the same object, -/// one revision on, with its samples unchanged. The v6 board rejects a v5 ride, and one unreadable -/// ride keeps the whole Rides menu from loading, so a development card is upgraded in place. -fn upgrade_v5_rides(store: &FlatStore) -> Result<(), StoreError> { - loop { - let mut found = None; - for entry in store.entries().filter(|e| e.kind == ObjectKind::Ride && e.flags == EntryFlags::NONE) { - if is_v5(store, &entry)? { - found = Some(entry); - break; - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - let Some(entry) = found else { return Ok(()) }; - let samples = entry.payload_len - V5_FOOTER_LEN as u64; - let mut footer = [0; FOOTER_LEN]; - let handle = store.open(entry.id, Some(entry.revision))?; - read_exact(store, &handle, samples, &mut footer[..V5_FOOTER_LEN])?; - footer[4] = obc_formats::ride::VERSION; - footer[6..8].copy_from_slice(&(FOOTER_LEN as u16).to_le_bytes()); - obc_formats::ride::decode_footer(&footer).map_err(|_| StoreError::Invalid)?; - let copy = |allocation: &mut Allocation| { - let mut buffer = [0; 500]; - let mut offset = 0; - while offset < samples { - let count = (samples - offset).min(buffer.len() as u64) as usize; - read_exact(store, &handle, offset, &mut buffer[..count])?; - store.write(allocation, &buffer[..count])?; - offset += count as u64; - } - store.write(allocation, &footer).map(|_| ()) - }; - let replaced = replace(store, &entry, copy, samples + FOOTER_LEN as u64); - store.close(handle); - replaced?; - } -} - -/// Whether a finished ride ends in a v5 footer over whole samples. -fn is_v5(store: &FlatStore, entry: &EntryMeta) -> Result { - let len = entry.payload_len; - if len < V5_FOOTER_LEN as u64 || !(len - V5_FOOTER_LEN as u64).is_multiple_of(SAMPLE_LEN as u64) { - return Ok(false); - } - let mut head = [0; 8]; - let handle = store.open(entry.id, Some(entry.revision))?; - let read = read_exact(store, &handle, len - V5_FOOTER_LEN as u64, &mut head); - store.close(handle); - read?; - Ok(head[..5] == *b"OBRF\x05" && u16::from_le_bytes([head[6], head[7]]) as usize == V5_FOOTER_LEN) -} - -/// Replace `entry` with `len` bytes that `fill` writes: the same object and name, one revision on. -fn replace( - store: &FlatStore, - entry: &EntryMeta, - fill: impl FnOnce(&mut Allocation) -> Result<(), StoreError>, - len: u64, -) -> Result<(), StoreError> { - let mut allocation = store.allocate(len)?; - fill(&mut allocation)?; - let revision = Revision(entry.revision.0.checked_add(1).ok_or(StoreError::Invalid)?); - let meta = EntryMeta { revision, payload_len: len, payload_crc: store.allocation_crc(&allocation)?, ..*entry }; - store - .commit(&[ - Mutation::Remove { id: entry.id, revision: entry.revision }, - Mutation::Put { meta, source: PutSource::Fresh(allocation) }, - ]) - .map(|_| ()) -} - -fn read_exact( - store: &FlatStore, - handle: &Handle, - offset: u64, - out: &mut [u8], -) -> Result<(), StoreError> { - let mut done = 0; - while done < out.len() { - let count = store.read(handle, offset + done as u64, &mut out[done..])?; - if count == 0 { - return Err(StoreError::Media); - } - done += count; - } - Ok(()) -} - -fn check_store(store: &FlatStore) -> Result<(), StoreError> { - if store.mode() != Mode::ReadWrite { - return Err(StoreError::ReadOnly); - } - if store.recovered_ride().is_some() { - return Err(StoreError::Busy); - } - Ok(()) -} - -fn payload_matches(store: &FlatStore, id: ObjectId, bytes: &[u8]) -> Result { - store - .with_source(id, None, |source| -> Result { - if source.len() != bytes.len() as u64 { - return Ok(false); - } - let mut buffer = [0; 512]; - for (index, chunk) in bytes.chunks(512).enumerate() { - source.read_at((index * 512) as u64, &mut buffer[..chunk.len()])?; - if &buffer[..chunk.len()] != chunk { - return Ok(false); - } - } - Ok(true) - })? - .map_err(|_| StoreError::Media) -} - -fn write_ride(store: &FlatStore, sensors: usize, start: u32) -> Result { - let mut allocation = store.allocate(PAYLOAD_LEN)?; - let mut crc = Crc32::new(); - let mut distance = 0.0; - let (mut climb, mut descent, mut hr_sum, mut power_sum) = (0, 0, 0, 0); - let (mut max_hr, mut max_power) = (0, 0); - let mut previous = point(0, sensors); - for index in 0..=INTERVALS { - let sample = point(index, sensors); - if index > 0 { - distance += obc_map_scene::ground_dist_m((previous.lon, previous.lat), (sample.lon, sample.lat)); - let rise = i32::from(sample.ele) - i32::from(previous.ele); - climb += rise.max(0) as u16; - descent += (-rise).max(0) as u16; - } - if index < INTERVALS { - hr_sum += u32::from(sample.hr.unwrap_or(0)); - power_sum += u32::from(sample.power.unwrap_or(0)); - } - max_hr = max_hr.max(sample.hr.unwrap_or(0)); - max_power = max_power.max(sample.power.unwrap_or(0)); - previous = sample; - let bytes = encode_record(&sample); - store.write(&mut allocation, &bytes)?; - crc.update(&bytes); - } - let duration = INTERVALS * STEP_SECONDS; - let mut footer = Footer::new( - NAMES[sensors], - start, - distance as u32, - duration, - (distance * 100.0 / duration as f32) as u16, - climb, - INTERVALS + 1, - (sensors >= 1).then_some((hr_sum / INTERVALS) as u8), - (sensors >= 1).then_some(max_hr), - None, - (sensors == 2).then_some((power_sum / INTERVALS) as u16), - (sensors == 2).then_some(max_power), - ); - footer.descent_m = descent; - footer.energy_kj = (sensors == 2).then_some(power_sum * STEP_SECONDS / 1000); - if sensors >= 1 { - footer.limits = EffortLimits { max_hr: 185, ftp_w: 250 }; - } - let bytes = encode_footer(&footer); - store.write(&mut allocation, &bytes)?; - crc.update(&bytes); - let id = store.next_object_id(); - let payload_crc = crc.finalize(); - store.commit(&[Mutation::Put { - meta: EntryMeta { - id, - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: PAYLOAD_LEN, - payload_crc, - name: DisplayName::new(NAMES[sensors]).ok_or(StoreError::Invalid)?, - added_at_utc: start, - }, - source: PutSource::Fresh(allocation), - }])?; - let handle = store.open(id, None)?; - let mut check = Crc32::new(); - let mut buffer = [0; 512]; - let mut offset = 0; - while offset < PAYLOAD_LEN { - let count = store.read(&handle, offset, &mut buffer)?; - if count == 0 { - return Err(StoreError::Media); - } - check.update(&buffer[..count]); - offset += count as u64; - } - store.close(handle); - if check.finalize() != payload_crc { - return Err(StoreError::Media); - } - Ok(id) -} diff --git a/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs b/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs new file mode 100644 index 000000000..76a256e1d --- /dev/null +++ b/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs @@ -0,0 +1,112 @@ +//! Factory-only demo ride writer. The factory image halts after this step. + +use obc_formats::io::ByteSource; +use obc_formats::ride::{checked_object_len, decode_footer, FOOTER_LEN, SAMPLE_LEN}; +use obc_formats::track::{decode_record, FLAG_SEGMENT_START}; +use obc_storage::flat::{ + BlockDevice, DisplayName, EntryFlags, EntryMeta, FlatStore, Mode, Mutation, ObjectId, ObjectKind, PutSource, + Revision, Store, StoreError, +}; + +/// Add one finished ride from validated bytes. An existing name must hold the same payload. +pub fn seed(store: &FlatStore, bytes: &[u8]) -> Result { + check_store(store)?; + let footer = decode_footer(bytes.last_chunk().ok_or(StoreError::Invalid)?).map_err(|_| StoreError::Invalid)?; + if !footer.is_demo + || footer.start_time == 0 + || footer.point_count < 2 + || checked_object_len(footer.point_count).map_err(|_| StoreError::Invalid)? != bytes.len() as u64 + { + return Err(StoreError::Invalid); + } + let mut last_time = None; + for sample in bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0 { + let flags = u16::from_le_bytes([sample[10], sample[11]]); + let point = decode_record(sample); + if flags & !FLAG_SEGMENT_START != 0 + || !(-180_000_000..=180_000_000).contains(&point.lon) + || !(-90_000_000..=90_000_000).contains(&point.lat) + || last_time.is_some_and(|time| point.t_ms < time) + { + return Err(StoreError::Invalid); + } + last_time = Some(point.t_ms); + } + let name = DisplayName::new(footer.name()).ok_or(StoreError::Invalid)?; + let mut rides = 0; + let mut existing = None; + for entry in store.entries() { + if entry.flags == EntryFlags::RECORDING { + return Err(StoreError::Busy); + } + if entry.kind == ObjectKind::Ride && entry.flags == EntryFlags::NONE { + rides += 1; + if entry.name == name { + if existing.is_some() { + return Err(StoreError::Invalid); + } + existing = Some(entry); + } + } + } + if !store.entries_ok() { + return Err(StoreError::Media); + } + if let Some(entry) = existing { + return if payload_matches(store, entry.id, bytes)? { Ok(entry.id) } else { Err(StoreError::Invalid) }; + } + if rides >= obc_app::MAX_RIDES { + return Err(StoreError::CatalogFull); + } + + let mut allocation = store.allocate(bytes.len() as u64)?; + for chunk in bytes.chunks(512) { + store.write(&mut allocation, chunk)?; + } + let id = store.next_object_id(); + store.commit(&[Mutation::Put { + meta: EntryMeta { + id, + revision: Revision(1), + kind: ObjectKind::Ride, + flags: EntryFlags::NONE, + payload_len: bytes.len() as u64, + payload_crc: obc_crc::crc32(bytes), + name, + added_at_utc: footer.start_time, + }, + source: PutSource::Fresh(allocation), + }])?; + if !payload_matches(store, id, bytes)? { + return Err(StoreError::Media); + } + Ok(id) +} + +fn check_store(store: &FlatStore) -> Result<(), StoreError> { + if store.mode() != Mode::ReadWrite { + return Err(StoreError::ReadOnly); + } + if store.recovered_ride().is_some() { + return Err(StoreError::Busy); + } + Ok(()) +} + +fn payload_matches(store: &FlatStore, id: ObjectId, bytes: &[u8]) -> Result { + store + .with_source(id, None, |source| -> Result { + if source.len() != bytes.len() as u64 { + return Ok(false); + } + let mut buffer = [0; 512]; + for (index, chunk) in bytes.chunks(512).enumerate() { + source.read_at((index * 512) as u64, &mut buffer[..chunk.len()])?; + if &buffer[..chunk.len()] != chunk { + return Ok(false); + } + } + Ok(true) + })? + .map_err(|_| StoreError::Media) +} diff --git a/firmware/obc-fw-nrf54l/src/flat_store.rs b/firmware/obc-fw-nrf54l/src/flat_store.rs index 32db0f8a4..f339cced6 100644 --- a/firmware/obc-fw-nrf54l/src/flat_store.rs +++ b/firmware/obc-fw-nrf54l/src/flat_store.rs @@ -381,6 +381,9 @@ pub(crate) const REQUEST_QUEUE_BYTES: usize = /// up atomicity across a batch — are both worse than the roughly 1 KB this costs. #[allow(dead_code, clippy::large_enum_variant)] pub(crate) enum Request { + ClearPersonalData { + store: obc_app::device_core::StoreIdentity, + }, WriteCheckpoint { scope: obc_app::device_core::StoreRevision, change: obc_app::navigator::CheckpointChange, @@ -533,6 +536,9 @@ pub(crate) enum Request { /// What one [`Request`] produced. #[allow(dead_code)] pub(crate) enum Outcome { + PersonalDataCleared { + done: bool, + }, CleanedRoute(Option), Metadata(Result<(), obc_app::metadata::MetadataError>), Allocated(Allocation), @@ -1083,6 +1089,17 @@ fn serve( check_route_change(store, id)?; store.commit(&[Mutation::Remove { id, revision }]).map(|_| Outcome::Done) } + Request::ClearPersonalData { store: identity } => { + if catalog_scope(store).store != identity { + return Err(StoreError::Invalid); + } + let batch = obc_storage::flat::personal_data::next(store)?; + let done = batch.is_empty(); + if !done { + store.commit(&batch)?; + } + Ok(Outcome::PersonalDataCleared { done }) + } Request::CleanupRoute { before_utc, store: identity, active } => { if catalog_scope(store).store != identity { return Err(StoreError::Invalid); diff --git a/firmware/obc-fw-nrf54l/src/main.rs b/firmware/obc-fw-nrf54l/src/main.rs index c4927489c..7d7567dc6 100644 --- a/firmware/obc-fw-nrf54l/src/main.rs +++ b/firmware/obc-fw-nrf54l/src/main.rs @@ -20,9 +20,8 @@ mod board; mod buzzer; // The raw-card flat store and the board adapter that binds it to sEMMC. -#[cfg(feature = "seed-rides")] -#[allow(dead_code)] -mod demo_rides; +#[cfg(feature = "factory-demo-ride")] +mod factory_demo_ride; mod flat_ride; mod flat_store; // The microSD host over Nordic's sEMMC soft peripheral on the FLPR: native 4-bit SD mode, @@ -865,35 +864,21 @@ async fn main(_spawner: Spawner) { // mount is bounded by the catalog it reads. let flat_started = embassy_time::Instant::now(); let flat = flat_store::mount_at_boot(); - #[cfg(feature = "seed-rides")] + #[cfg(feature = "factory-demo-ride")] { - #[cfg(demo_ride_file)] - let result = demo_rides::seed_file(flat, include_bytes!(concat!(env!("OUT_DIR"), "/demo_ride.obcr"))); - #[cfg(not(demo_ride_file))] - let result = { - let start = option_env!("OBC_DEMO_RIDE_START").unwrap_or("0").parse().unwrap_or(0); - demo_rides::seed(flat, start) - }; - match result { - #[cfg(demo_ride_file)] - Ok(id) => { - info!("demo ride: object {}", id.0); - info!("demo rides: complete"); - } - #[cfg(not(demo_ride_file))] - Ok(ids) => { - for (name, id) in demo_rides::NAMES.iter().zip(ids) { - info!("demo rides: {} = object {}", name, id.0); + match factory_demo_ride::seed(flat, include_bytes!(concat!(env!("OUT_DIR"), "/demo_ride.obcr"))) { + Ok(id) => info!("factory demo ride: complete, object {}", id.0), + Err(error) => defmt::error!("factory demo ride: refused: {:?}", defmt::Debug2Format(&error)), + } + if cfg!(feature = "factory-demo-ride") { + let mut watchdog = board::watchdog!(p.WDT0, ride::WDT_TIMEOUT_TICKS).ok().map(|(_, [handle])| handle); + loop { + if let Some(handle) = &mut watchdog { + handle.pet(); } - info!("demo rides: complete"); + led.toggle(); + Timer::after_millis(500).await; } - Err(error) => { - defmt::error!("demo rides: refused: {:?}", defmt::Debug2Format(&error)); - idle_blink(&mut led).await - } - } - if cfg!(demo_ride_file) { - idle_blink(&mut led).await } } let flat_catalog = flat_store::report(flat, flat_started.elapsed().as_micros()); @@ -1041,6 +1026,7 @@ async fn main(_spawner: Spawner) { // between app entry and this line must complete well inside one WDT period (24 s). If it // does not, the dog resets a healthy trial image and the bootloader rolls it back. Never move // a blocking or open-ended retry loop above this point. + #[cfg(not(feature = "factory-demo-ride"))] let wdt_handle = match board::watchdog!(p.WDT0, ride::WDT_TIMEOUT_TICKS) { Ok((_wdt, [handle])) => Some(handle), Err(_) => { @@ -1048,6 +1034,8 @@ async fn main(_spawner: Spawner) { None } }; + #[cfg(feature = "factory-demo-ride")] + let wdt_handle: Option = None; // The settings store moves behind one async mutex, so the ride loop and both link planes can // lock it per operation. The flat store owns the card through its separate command seam. diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 5d06b374c..c0b856018 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -1112,6 +1112,19 @@ pub(crate) async fn run_app( if let Some(effect) = exec.effects.catalog.take() { use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; match effect { + CatalogEffect::ClearPersonalData { token, store } => { + crate::flat_store::reconcile_route(flat, None); + match crate::flat_store::writer().ok_or(()).and_then(|w| { + w.try_call(crate::flat_store::Request::ClearPersonalData { store }, &CATALOG_STORE_REPLY) + }) { + Ok(ticket) => exec.catalog = Some(CatalogRemoval { ticket, token, object: 0 }), + Err(()) => RideExec::deliver( + &mut exec.outcomes.catalog, + CatalogOutcome::Failed { token, error: CatalogError::RemoveFailed }, + "catalog", + ), + } + } CatalogEffect::RemoveOrphanRoutes { token } => { let heads = crate::flat_store::route_heads(flat, app.orphan_routes()); let result = if heads.is_empty() { @@ -1216,6 +1229,13 @@ pub(crate) async fn run_app( crate::flat_store::writer().and_then(|w| w.try_result(removal.ticket, &CATALOG_STORE_REPLY)); match answer { None => exec.catalog = Some(removal), + Some(Ok(crate::flat_store::Outcome::PersonalDataCleared { done })) => { + RideExec::deliver( + &mut exec.outcomes.catalog, + CatalogOutcome::PersonalDataCleared { token: removal.token, done }, + "catalog", + ); + } Some(Ok(crate::flat_store::Outcome::CleanedRoute(object))) => { let outcome = match object { Some(object) => { diff --git a/firmware/obc-storage/src/flat/mod.rs b/firmware/obc-storage/src/flat/mod.rs index c8397422d..5405dc00b 100644 --- a/firmware/obc-storage/src/flat/mod.rs +++ b/firmware/obc-storage/src/flat/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub(crate) mod journal; pub(crate) mod layout; pub mod metadata; +pub mod personal_data; pub(crate) mod raw; pub mod seam; pub mod source; diff --git a/firmware/obc-storage/src/flat/personal_data.rs b/firmware/obc-storage/src/flat/personal_data.rs new file mode 100644 index 000000000..399579aa4 --- /dev/null +++ b/firmware/obc-storage/src/flat/personal_data.rs @@ -0,0 +1,87 @@ +//! Bounded factory-reset deletion. Maps and firmware packages are system data. +use super::{store::MAX_BATCH, BlockDevice, FlatStore, Mutation, ObjectKind, Store, StoreError}; + +/// Remove metadata first so no checkpoint or archive proof outlives its personal objects. +pub fn next(store: &FlatStore) -> Result, StoreError> { + let mut batch = heapless::Vec::new(); + for metadata in [true, false] { + for entry in store.entries().filter(|entry| { + if metadata { + entry.kind == ObjectKind::Metadata + } else { + matches!(entry.kind, ObjectKind::Route | ObjectKind::Trip | ObjectKind::Ride) + } + }) { + batch.push(Mutation::Remove { id: entry.id, revision: entry.revision }).map_err(|_| StoreError::Invalid)?; + if batch.is_full() { + break; + } + } + if !store.entries_ok() { + return Err(StoreError::Media); + } + if !batch.is_empty() { + break; + } + } + Ok(batch) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::flat::{sim::SparseDisk, EntryFlags, EntryMeta, PutSource, Revision, Store, StoreId}; + + fn put(store: &FlatStore<&SparseDisk>, kind: ObjectKind, bytes: &[u8]) -> EntryMeta { + let mut allocation = store.allocate(bytes.len() as u64).unwrap(); + store.write(&mut allocation, bytes).unwrap(); + let meta = EntryMeta { + id: store.next_object_id(), + revision: Revision(1), + kind, + flags: EntryFlags::NONE, + payload_len: bytes.len() as u64, + payload_crc: obc_crc::crc32(bytes), + name: Default::default(), + added_at_utc: 0, + }; + store.commit(&[Mutation::Put { meta, source: PutSource::Fresh(allocation) }]).unwrap(); + meta + } + + #[test] + fn reset_removes_metadata_first_and_all_personal_objects_across_reboots() { + let disk = SparseDisk::blank(2_000_000, 5); + let identity = StoreId([7; 16]); + let mut store = FlatStore::initialize(&disk, identity).unwrap(); + let system = + [ObjectKind::MapShard, ObjectKind::MapSetManifest, ObjectKind::UpdatePackage, ObjectKind::RollbackReserve] + .map(|kind| put(&store, kind, b"system data")); + for i in 0..75 { + put(&store, [ObjectKind::Route, ObjectKind::Trip, ObjectKind::Ride][i % 3], b"personal data"); + } + let metadata = put(&store, ObjectKind::Metadata, b"personal metadata"); + let first = next(&store).unwrap(); + assert_eq!(first.len(), 1); + assert!(matches!(first[0], Mutation::Remove { id, .. } if id == metadata.id)); + store.commit(&first).unwrap(); + loop { + store = FlatStore::mount(&disk); + let batch = next(&store).unwrap(); + if batch.is_empty() { + break; + } + assert!(batch.len() <= MAX_BATCH); + store.commit(&batch).unwrap(); + } + assert_eq!(store.store_id(), identity); + assert_eq!(store.entries().collect::>(), system); + for meta in system { + let handle = store.open(meta.id, Some(meta.revision)).unwrap(); + let mut bytes = [0; 11]; + assert_eq!(store.read(&handle, 0, &mut bytes).unwrap(), bytes.len()); + assert_eq!(&bytes, b"system data"); + store.close(handle); + } + } +} diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index e47f11524..2dd6ab9ee 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -227,9 +227,9 @@ ca550709d320135ff7f1cf030674ad5e08afd2c789dd06aef6c992b2b7cc778b quick-power-ho 3ad379a04dd18384defbba00d2d36053cea73cbff7f4d4c4fc81cb9d96445034 quick-root-fr.png 326671ddbb2b6505e8235eb7bd5525166bbaf51b729189d3e51f041065b5aa6e quick-root-no-backlight.png 39e4c27dd6ce6a963cf7f9134b9020c1d1efc61e61a64ae47f70b02fc2b405ce quick-root.png -88dc4b0a16ecdc8cb8c67fc414f13b8c37a3a8985466be44574af99e9c39aa5a reset-confirm-de.png -083471cd7f91717c0ab8c73c799ef0d798dd76b511c090b8e8053438351ce43b reset-confirm-es.png -ffb316c1bf0f8c11f533342b9b451ff40db1a4a4ccb76b6cc8de84cb017550b9 reset-confirm-fr.png +f36e8fa15795f0505e8847a03b253e1992d9036e4202f500998fd9697738b8ac reset-confirm-de.png +e63ff30033c113641fe5edef983c859169194e2eed6aa2b28c9ee0dcde8928ec reset-confirm-es.png +056181875ec762edc4ac9cad9b8f33c484976dc7547edec0db051d7c7077f02a reset-confirm-fr.png 49da9fb814667eee32b85a7fee6c00fb8aec8a2a451a6334f54a828bd25a839b reset-de.png 72a4212e578aba76210f7a02c8ae2ab7aa926700e574b44bf30b70e8527719ed reset-es.png fca2f95f0ac16aa6b95c261e522ef225f8deabbb21de39df77158ce14264395b reset-fr.png diff --git a/fixtures/sources/kandel-ride/README.md b/fixtures/sources/kandel-ride/README.md index 83488ef10..1989bd6b2 100644 --- a/fixtures/sources/kandel-ride/README.md +++ b/fixtures/sources/kandel-ride/README.md @@ -9,7 +9,7 @@ Build the finished ride from the repository root: cargo run -p obc-replay --example gpx_to_ride -- \ fixtures/sources/kandel-ride/kandel.gpx \ fixtures/build/kandel-ride/kandel.obcr \ - 1790236800 'Kandel (simulated)' + 1790236800 'Kandel (simulated)' --demo ``` The output is a recorded-ride v6 object. The generator keeps every GPX track point and adds @@ -25,13 +25,11 @@ Use the J4 debug cable. Keep the exact normal firmware ELF before the temporary The transfer protocol does not accept ride uploads. ```sh -OBC_DEMO_RIDE_FILE="$PWD/fixtures/build/kandel-ride/kandel.obcr" obc flash seed-rides +OBC_DEMO_RIDE_FILE="$PWD/fixtures/build/kandel-ride/kandel.obcr" obc flash factory-demo-ride ``` -Wait for `demo rides: complete`, then press Ctrl-C. The temporary image stays idle. +Wait for `factory demo ride: complete`, then press Ctrl-C. The temporary image stays idle. Restore the saved normal firmware with `obc board run /absolute/path/to/normal.elf`. The ride remains on the card and syncs to the companion app like a recorded ride. -Reusing the same fixture does not add a duplicate. A v5 ride with the same name is replaced -in place; any other different payload with the same name is refused. The seeder rewrites every -other v5 ride on the card as a v6 ride without limits, with its samples unchanged, because the -v6 firmware cannot read v5 rides. Existing routes and maps are preserved. +Reusing the same fixture does not add a duplicate. A different payload with the same name is +refused. The factory step requires a ride with the demo flag set. Rebuild the file before use. diff --git a/host/obc-host-core/src/dispatch.rs b/host/obc-host-core/src/dispatch.rs index a712a38b5..d00f59720 100644 --- a/host/obc-host-core/src/dispatch.rs +++ b/host/obc-host-core/src/dispatch.rs @@ -611,6 +611,10 @@ impl HostLoop { trips: &mut dyn TripCatalog, ) -> CatalogOutcome { match effect { + CatalogEffect::ClearPersonalData { token, store } => match routes.clear_personal_data(store) { + Ok(done) => CatalogOutcome::PersonalDataCleared { token, done }, + Err(error) => CatalogOutcome::Failed { token, error }, + }, CatalogEffect::RemoveOrphanRoutes { token } => { let orphans: Vec<_> = app.orphan_routes().collect(); match routes.retract_generated_routes(&orphans) { diff --git a/host/obc-host-core/src/flat_routes.rs b/host/obc-host-core/src/flat_routes.rs index 965f109f5..d5439c002 100644 --- a/host/obc-host-core/src/flat_routes.rs +++ b/host/obc-host-core/src/flat_routes.rs @@ -125,6 +125,19 @@ impl FlatRouteStore { } impl RouteRepository for FlatRouteStore { + fn clear_personal_data(&mut self, identity: obc_app::device_core::StoreIdentity) -> Result { + let mut owner = self.owner.0.lock().map_err(|_| CatalogError::Unreadable)?; + let store = owner.ready().map_err(|_| CatalogError::RemountRequired)?; + if scope(store).store != identity { + return Err(CatalogError::Stale); + } + let batch = obc_storage::flat::personal_data::next(store).map_err(|_| CatalogError::Unreadable)?; + if batch.is_empty() { + return Ok(true); + } + owner.commit(&batch).map_err(|_| CatalogError::RemoveFailed)?; + Ok(false) + } fn set_route_clock(&mut self, utc: Option) { if let Ok(owner) = self.owner.0.lock() { if let Ok(store) = owner.ready() { diff --git a/host/obc-host-core/src/repo.rs b/host/obc-host-core/src/repo.rs index 6917a8580..f6525c88d 100644 --- a/host/obc-host-core/src/repo.rs +++ b/host/obc-host-core/src/repo.rs @@ -62,6 +62,10 @@ impl ByteSource for RouteLease { /// Route projections, retained readers and physical writes used by the shared host executor. pub trait RouteRepository { + /// Delete one batch of personal objects from the shared card; true means it is empty. + fn clear_personal_data(&mut self, _store: obc_app::device_core::StoreIdentity) -> Result { + Err(CatalogError::Unsupported) + } fn set_route_clock(&mut self, _utc: Option) {} fn cleanup_route( &mut self, diff --git a/host/obc-host-core/tests/demo_rides.rs b/host/obc-host-core/tests/demo_rides.rs deleted file mode 100644 index 92f79463c..000000000 --- a/host/obc-host-core/tests/demo_rides.rs +++ /dev/null @@ -1,252 +0,0 @@ -#[path = "../../../firmware/obc-fw-nrf54l/src/demo_rides.rs"] -mod demo_rides; - -use obc_formats::io::SliceSource; -use obc_formats::ride::{decode_footer, encode_footer, EffortLimits, Footer, FOOTER_LEN}; -use obc_formats::track::{decode_record, RECORD_LEN}; -use obc_storage::flat::{ - sim::SparseDisk, DisplayName, EntryFlags, EntryMeta, FlatStore, Mutation, ObjectId, ObjectKind, PutSource, - Revision, Store, StoreError, StoreId, -}; - -fn finished_file(name: &str, start: u32) -> Vec { - let sample = obc_formats::track::encode_record(&obc_ports::TrackPoint { - lon: 7_800_000, - lat: 48_000_000, - ele: 200, - t_ms: 0, - segment_start: true, - hr: None, - cadence: None, - power: None, - }); - let footer = encode_footer(&Footer::new(name, start, 0, 0, 0, 0, 40, None, None, None, None, None)); - let mut bytes = sample.repeat(40); - bytes.extend_from_slice(&footer); - bytes -} - -#[test] -fn finished_rides_survive_remount_with_distinct_sensor_data_and_no_duplicates() { - let disk = SparseDisk::blank(131_072, 7); - let store = FlatStore::initialize(&disk, StoreId([7; 16])).unwrap(); - let mut allocation = store.allocate(4).unwrap(); - store.write(&mut allocation, b"keep").unwrap(); - let existing = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Route, - flags: EntryFlags::NONE, - payload_len: 4, - payload_crc: obc_crc::crc32(b"keep"), - name: DisplayName::new("Existing route").unwrap(), - added_at_utc: 123, - }; - store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); - let ids = demo_rides::seed(&store, 1_700_000_000).unwrap(); - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed(&store, 1_800_000_000).unwrap(), ids); - assert_eq!(store.sequence(), sequence); - assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); - let mut preserved = [0; 4]; - store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); - assert_eq!(&preserved, b"keep"); - for (index, id) in ids.into_iter().enumerate() { - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - assert_eq!(entry.kind, ObjectKind::Ride); - let mut bytes = vec![0; entry.payload_len as usize]; - let handle = store.open(id, None).unwrap(); - assert_eq!(store.read(&handle, 0, &mut bytes).unwrap(), bytes.len()); - assert_eq!(obc_crc::crc32(&bytes), entry.payload_crc); - let info = obc_route::RideInfo::read(&SliceSource(&bytes)).unwrap(); - assert_eq!(info.name.as_str(), demo_rides::NAMES[index]); - assert_eq!(info.moving_time_s, 1800); - assert!((8_000..12_000).contains(&info.distance_m), "{} m", info.distance_m); - assert_eq!(info.start_time, 1_700_000_000 + index as u32 * 86_400); - assert_eq!(info.avg_hr.is_some(), index >= 1); - assert_eq!(info.avg_power.is_some(), index == 2); - assert_eq!(info.energy_kj.is_some(), index == 2); - let points: Vec<_> = bytes[..info.point_count as usize * RECORD_LEN] - .as_chunks::() - .0 - .iter() - .map(decode_record) - .collect(); - assert_eq!(points.last().unwrap().t_ms, 1_800_000); - for (i, point) in points.iter().enumerate() { - assert_eq!(point.t_ms, i as u32 * 5000); - assert_eq!(point.hr.is_some(), index >= 1); - assert_eq!(point.power.is_some(), index == 2); - assert_eq!(point.cadence, None); - assert_eq!(point.segment_start, i == 0); - } - } -} - -#[test] -fn unformatted_card_is_refused_without_writes() { - let disk = SparseDisk::blank(131_072, 8); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed(&store, 1_700_000_000), Err(StoreError::ReadOnly)); - assert_eq!(demo_rides::seed_file(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::ReadOnly)); - assert!(disk.write_log().is_empty()); -} - -#[test] -fn active_recording_is_refused_without_writes() { - let disk = SparseDisk::blank(131_072, 9); - let store = FlatStore::initialize(&disk, StoreId([9; 16])).unwrap(); - let allocation = store.allocate(1024 * 1024).unwrap(); - store - .commit(&[Mutation::Put { - meta: EntryMeta { - id: ObjectId(1), - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::RECORDING, - payload_len: 0, - payload_crc: 0, - name: DisplayName::new("Recording").unwrap(), - added_at_utc: 0, - }, - source: PutSource::Fresh(allocation), - }]) - .unwrap(); - let writes = disk.write_log().len(); - assert_eq!(demo_rides::seed(&store, 1_700_000_000), Err(StoreError::Busy)); - assert_eq!(demo_rides::seed_file(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::Busy)); - assert_eq!(disk.write_log().len(), writes); -} - -#[test] -fn file_seed_survives_remount_and_preserves_existing_objects() { - let disk = SparseDisk::blank(131_072, 10); - let store = FlatStore::initialize(&disk, StoreId([10; 16])).unwrap(); - let mut allocation = store.allocate(4).unwrap(); - store.write(&mut allocation, b"keep").unwrap(); - let existing = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Route, - flags: EntryFlags::NONE, - payload_len: 4, - payload_crc: obc_crc::crc32(b"keep"), - name: DisplayName::new("Existing route").unwrap(), - added_at_utc: 123, - }; - store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); - - let bytes = finished_file("Kandel", 1_700_000_000); - let id = demo_rides::seed_file(&store, &bytes).unwrap(); - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed_file(&store, &bytes), Ok(id)); - assert_eq!(store.sequence(), sequence); - assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); - let mut preserved = [0; 4]; - store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); - assert_eq!(&preserved, b"keep"); - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - assert_eq!(entry.payload_crc, obc_crc::crc32(&bytes)); - let mut stored = vec![0; bytes.len()]; - store.read(&store.open(id, None).unwrap(), 0, &mut stored).unwrap(); - assert_eq!(stored, bytes); - assert_eq!(obc_route::RideInfo::read(&SliceSource(&stored)).unwrap().name.as_str(), "Kandel"); -} - -#[test] -fn file_seed_refuses_invalid_or_conflicting_payload_without_writes() { - let disk = SparseDisk::blank(131_072, 11); - let store = FlatStore::initialize(&disk, StoreId([11; 16])).unwrap(); - let bytes = finished_file("Kandel", 1_700_000_000); - let mut invalid = bytes.clone(); - invalid.pop(); - let mut reserved_flags = bytes.clone(); - reserved_flags[10] = 2; - let mut bad_latitude = bytes.clone(); - bad_latitude[4..8].copy_from_slice(&91_000_000i32.to_le_bytes()); - let mut backward_time = bytes.clone(); - backward_time[RECORD_LEN + 12..RECORD_LEN + 16].copy_from_slice(&1u32.to_le_bytes()); - let writes = disk.write_log().len(); - for invalid in [&invalid, &reserved_flags, &bad_latitude, &backward_time] { - assert_eq!(demo_rides::seed_file(&store, invalid), Err(StoreError::Invalid)); - assert_eq!(disk.write_log().len(), writes); - } - - demo_rides::seed_file(&store, &bytes).unwrap(); - let conflicting = finished_file("Kandel", 1_800_000_000); - let writes = disk.write_log().len(); - assert_eq!(demo_rides::seed_file(&store, &conflicting), Err(StoreError::Invalid)); - assert_eq!(disk.write_log().len(), writes); -} - -/// A v5 ride from the previous firmware: the same samples under the 150-byte v5 footer. -fn v5_file(name: &str, start: u32) -> Vec { - let mut bytes = finished_file(name, start); - bytes.truncate(bytes.len() - 4); - let footer = bytes.len() - 150; - bytes[footer + 4] = 5; - bytes[footer + 6..footer + 8].copy_from_slice(&150u16.to_le_bytes()); - bytes -} - -fn put_ride(store: &FlatStore<&SparseDisk>, bytes: &[u8], name: &str) -> EntryMeta { - let mut allocation = store.allocate(bytes.len() as u64).unwrap(); - store.write(&mut allocation, bytes).unwrap(); - let meta = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: bytes.len() as u64, - payload_crc: obc_crc::crc32(bytes), - name: DisplayName::new(name).unwrap(), - added_at_utc: 1, - }; - store.commit(&[Mutation::Put { meta, source: PutSource::Fresh(allocation) }]).unwrap(); - meta -} - -fn stored(store: &FlatStore<&SparseDisk>, id: ObjectId) -> (Revision, Vec) { - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - let mut bytes = vec![0; entry.payload_len as usize]; - let handle = store.open(id, None).unwrap(); - store.read(&handle, 0, &mut bytes).unwrap(); - store.close(handle); - (entry.revision, bytes) -} - -/// A card from the v5 firmware: its copy of the fixture is replaced in place, and every other v5 -/// ride becomes the same object one revision on, with its samples and a v6 footer without limits. -#[test] -fn file_seed_replaces_the_v5_fixture_and_upgrades_other_v5_rides_in_place() { - let disk = SparseDisk::blank(131_072, 12); - let store = FlatStore::initialize(&disk, StoreId([12; 16])).unwrap(); - let old_fixture = put_ride(&store, &v5_file("Kandel", 1_700_000_000), "Kandel"); - let recorded = v5_file("Morning ride", 1_600_000_000); - let other = put_ride(&store, &recorded, "Morning ride"); - // More rides than the store has open-object slots, so a leaked handle would refuse the seed. - let more: Vec<_> = - (0..6).map(|i| put_ride(&store, &v5_file("Older ride", 1_500_000_000 + i), "Older ride")).collect(); - - let mut fixture = finished_file("Kandel", 1_700_000_000); - let limits = fixture.len() - 4; - fixture[limits] = 185; - fixture[limits + 2..].copy_from_slice(&250u16.to_le_bytes()); - assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); - assert_eq!(stored(&store, old_fixture.id), (Revision(2), fixture.clone())); - - let (revision, upgraded) = stored(&store, other.id); - assert_eq!(revision, Revision(2)); - assert_eq!(upgraded[..40 * RECORD_LEN], recorded[..40 * RECORD_LEN], "the samples are unchanged"); - let footer = decode_footer(upgraded[upgraded.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); - assert_eq!((footer.name(), footer.start_time), ("Morning ride", 1_600_000_000)); - assert_eq!(footer.limits, EffortLimits::default()); - assert!(more.iter().all(|ride| stored(&store, ride.id).0 == Revision(2)), "every v5 ride is upgraded"); - - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); - assert_eq!(store.sequence(), sequence, "a second run writes nothing"); -} diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index 507bbc720..735295784 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -392,11 +392,14 @@ impl CoreHarness { let kind = match effect { CatalogEffect::RemoveObject { kind, .. } => Some(kind), CatalogEffect::ReadCatalog { .. } + | CatalogEffect::ClearPersonalData { .. } | CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => None, }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::ReadCatalog { token } => { @@ -890,7 +893,9 @@ impl CoreHarness { for _ in 0..4 { let effect = self.next_catalog_effect(); match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { .. } => return effect, diff --git a/host/obc-host-core/tests/factory_demo_ride.rs b/host/obc-host-core/tests/factory_demo_ride.rs new file mode 100644 index 000000000..c87fe61ff --- /dev/null +++ b/host/obc-host-core/tests/factory_demo_ride.rs @@ -0,0 +1,127 @@ +#[path = "../../../firmware/obc-fw-nrf54l/src/factory_demo_ride.rs"] +mod factory_demo_ride; + +use obc_formats::io::SliceSource; +use obc_formats::ride::{encode_footer, Footer}; +use obc_formats::track::RECORD_LEN; +use obc_storage::flat::{ + sim::SparseDisk, DisplayName, EntryFlags, EntryMeta, FlatStore, Mutation, ObjectId, ObjectKind, PutSource, + Revision, Store, StoreError, StoreId, +}; + +fn finished_file(name: &str, start: u32) -> Vec { + let sample = obc_formats::track::encode_record(&obc_ports::TrackPoint { + lon: 7_800_000, + lat: 48_000_000, + ele: 200, + t_ms: 0, + segment_start: true, + hr: None, + cadence: None, + power: None, + }); + let mut footer = Footer::new(name, start, 0, 0, 0, 0, 40, None, None, None, None, None); + footer.is_demo = true; + let footer = encode_footer(&footer); + let mut bytes = sample.repeat(40); + bytes.extend_from_slice(&footer); + bytes +} + +#[test] +fn unformatted_card_is_refused_without_writes() { + let disk = SparseDisk::blank(131_072, 8); + let store = FlatStore::mount(&disk); + assert_eq!(factory_demo_ride::seed(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::ReadOnly)); + assert!(disk.write_log().is_empty()); +} + +#[test] +fn active_recording_is_refused_without_writes() { + let disk = SparseDisk::blank(131_072, 9); + let store = FlatStore::initialize(&disk, StoreId([9; 16])).unwrap(); + let allocation = store.allocate(1024 * 1024).unwrap(); + store + .commit(&[Mutation::Put { + meta: EntryMeta { + id: ObjectId(1), + revision: Revision(1), + kind: ObjectKind::Ride, + flags: EntryFlags::RECORDING, + payload_len: 0, + payload_crc: 0, + name: DisplayName::new("Recording").unwrap(), + added_at_utc: 0, + }, + source: PutSource::Fresh(allocation), + }]) + .unwrap(); + let writes = disk.write_log().len(); + assert_eq!(factory_demo_ride::seed(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::Busy)); + assert_eq!(disk.write_log().len(), writes); +} + +#[test] +fn file_seed_survives_remount_and_preserves_existing_objects() { + let disk = SparseDisk::blank(131_072, 10); + let store = FlatStore::initialize(&disk, StoreId([10; 16])).unwrap(); + let mut allocation = store.allocate(4).unwrap(); + store.write(&mut allocation, b"keep").unwrap(); + let existing = EntryMeta { + id: store.next_object_id(), + revision: Revision(1), + kind: ObjectKind::Route, + flags: EntryFlags::NONE, + payload_len: 4, + payload_crc: obc_crc::crc32(b"keep"), + name: DisplayName::new("Existing route").unwrap(), + added_at_utc: 123, + }; + store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); + + let bytes = finished_file("Kandel", 1_700_000_000); + let id = factory_demo_ride::seed(&store, &bytes).unwrap(); + let sequence = store.sequence(); + let store = FlatStore::mount(&disk); + assert_eq!(factory_demo_ride::seed(&store, &bytes), Ok(id)); + assert_eq!(store.sequence(), sequence); + assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); + let mut preserved = [0; 4]; + store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); + assert_eq!(&preserved, b"keep"); + let entry = store.entries().find(|entry| entry.id == id).unwrap(); + assert_eq!(entry.payload_crc, obc_crc::crc32(&bytes)); + let mut stored = vec![0; bytes.len()]; + store.read(&store.open(id, None).unwrap(), 0, &mut stored).unwrap(); + assert_eq!(stored, bytes); + assert_eq!(obc_route::RideInfo::read(&SliceSource(&stored)).unwrap().name.as_str(), "Kandel"); +} + +#[test] +fn file_seed_refuses_invalid_or_conflicting_payload_without_writes() { + let disk = SparseDisk::blank(131_072, 11); + let store = FlatStore::initialize(&disk, StoreId([11; 16])).unwrap(); + let bytes = finished_file("Kandel", 1_700_000_000); + let mut invalid = bytes.clone(); + invalid.pop(); + let mut reserved_flags = bytes.clone(); + reserved_flags[10] = 2; + let mut bad_latitude = bytes.clone(); + bad_latitude[4..8].copy_from_slice(&91_000_000i32.to_le_bytes()); + let mut backward_time = bytes.clone(); + backward_time[RECORD_LEN + 12..RECORD_LEN + 16].copy_from_slice(&1u32.to_le_bytes()); + let mut not_demo = bytes.clone(); + let flags = not_demo.len() - obc_formats::ride::FOOTER_LEN + 33; + not_demo[flags] = 0; + let writes = disk.write_log().len(); + for invalid in [&invalid, &reserved_flags, &bad_latitude, &backward_time, ¬_demo] { + assert_eq!(factory_demo_ride::seed(&store, invalid), Err(StoreError::Invalid)); + assert_eq!(disk.write_log().len(), writes); + } + + factory_demo_ride::seed(&store, &bytes).unwrap(); + let conflicting = finished_file("Kandel", 1_800_000_000); + let writes = disk.write_log().len(); + assert_eq!(factory_demo_ride::seed(&store, &conflicting), Err(StoreError::Invalid)); + assert_eq!(disk.write_log().len(), writes); +} diff --git a/host/obc-replay/examples/gpx_to_ride.rs b/host/obc-replay/examples/gpx_to_ride.rs index 174ebc786..7e7b1fbf5 100644 --- a/host/obc-replay/examples/gpx_to_ride.rs +++ b/host/obc-replay/examples/gpx_to_ride.rs @@ -1,4 +1,4 @@ -//! Build a bounded, deterministic finished ride for device development. +//! Build a bounded, deterministic finished ride with synthetic sensors. use std::{env, fs, path::Path}; @@ -18,9 +18,15 @@ const EARTH_M: f64 = 6_371_000.0; const DEFAULT_LIMITS: EffortLimits = EffortLimits { max_hr: 185, ftp_w: 250 }; fn main() -> Result<(), String> { - let args: Vec<_> = env::args().collect(); + let mut args: Vec<_> = env::args().collect(); + let is_demo = args.last().is_some_and(|arg| arg == "--demo"); + if is_demo { + args.pop(); + } if args.len() != 5 && args.len() != 7 { - return Err("usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME [MAX_HR_BPM FTP_W]".into()); + return Err( + "usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME [MAX_HR_BPM FTP_W] [--demo]".into() + ); } let start_time = args[3].parse::().map_err(|_| "START_UNIX_S must be a Unix timestamp in seconds")?; let limits = match args.get(5..7) { @@ -35,7 +41,7 @@ fn main() -> Result<(), String> { return Err(format!("GPX exceeds {MAX_GPX_BYTES} bytes")); } let track = Track::load(input)?; - let bytes = build_ride(&track, start_time, &args[4], limits)?; + let bytes = build_ride(&track, start_time, &args[4], limits, is_demo)?; let output = Path::new(&args[2]); if let Some(parent) = output.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; @@ -45,11 +51,27 @@ fn main() -> Result<(), String> { Ok(()) } -fn build_ride(track: &Track, start_time: u32, name: &str, limits: EffortLimits) -> Result, String> { - let points = &track.points; - if !(2..=MAX_POINTS).contains(&points.len()) { +fn build_ride( + track: &Track, + start_time: u32, + name: &str, + limits: EffortLimits, + is_demo: bool, +) -> Result, String> { + if !(2..=MAX_POINTS).contains(&track.points.len()) { return Err(format!("ride needs 2..={MAX_POINTS} track points")); } + let mut points = track.points.clone(); + if is_demo { + if points.windows(2).any(|pair| pair[1].t < pair[0].t) { + return Err("track times must not go backwards".into()); + } + // Whole-second GPX timestamps can repeat. Keep every demo point and give its + // synthetic sensor interval at least one second. + for i in 1..points.len() { + points[i].t = points[i].t.max(points[i - 1].t + 1.0); + } + } let mut distance = vec![0.0; points.len()]; let mut climb = DeadBand::::new(); for (i, p) in points.iter().enumerate() { @@ -144,6 +166,7 @@ fn build_ride(track: &Track, start_time: u32, name: &str, limits: EffortLimits) Some(avg(power_sum) as u16), Some(max_power), ); + footer.is_demo = is_demo; footer.descent_m = climb.descent().round() as u16; footer.energy_kj = Some((energy_j / 1000.0).round() as u32); footer.bike = BikeType::Road; @@ -185,13 +208,14 @@ mod tests { }) .collect(), }; - let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(); + let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS, false).unwrap(); let footer = decode_footer(bytes[bytes.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); assert_eq!(bytes.len() as u64, checked_object_len(track.points.len() as u32).unwrap()); assert_eq!(footer.point_count, track.points.len() as u32); assert_eq!(footer.name(), "Kandel (simulated)"); assert_eq!(footer.start_time, 1_790_236_800); assert_eq!(footer.limits, DEFAULT_LIMITS); + assert!(!footer.is_demo); assert!(footer.climb_m >= 20 && footer.descent_m >= 20); let records: Vec<_> = bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0.iter().map(decode_record).collect(); @@ -203,7 +227,30 @@ mod tests { assert!(records[1].power.unwrap() > records[6].power.unwrap()); assert_eq!((records[6].power, records[6].cadence), (Some(0), Some(0))); assert!(records[6].hr.unwrap() > 105, "heart rate should lag the descent"); - assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(), bytes); + assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS, false).unwrap(), bytes); + } + + #[test] + fn factory_ride_uses_the_complete_app_route_and_marks_its_synthetic_sensors() { + let input = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx"); + let track = Track::load(&input).unwrap(); + let bytes = build_ride(&track, 1_767_258_000, "Grimsel Pass", DEFAULT_LIMITS, true).unwrap(); + let footer = decode_footer(bytes[bytes.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); + assert_eq!(track.points.len(), 3_003); + assert_eq!(footer.point_count, 3_003); + assert_eq!(bytes.len(), 60_214); + assert!(footer.is_demo); + assert_eq!(footer.name(), "Grimsel Pass"); + assert_eq!(footer.start_time, 1_767_258_000); + assert_eq!(footer.limits, DEFAULT_LIMITS); + let records = bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0; + assert!(records.windows(2).all(|pair| decode_record(&pair[0]).t_ms < decode_record(&pair[1]).t_ms)); + for (source, record) in track.points.iter().zip(records) { + let sample = decode_record(record); + assert_eq!((sample.lat, sample.lon), (source.lat, source.lon)); + assert!(sample.hr.is_some() && sample.cadence.is_some() && sample.power.is_some()); + } } #[test] @@ -214,9 +261,9 @@ mod tests { GpxPoint { lat: 48_000_000, lon: 8_001_000, ele: None, t: 10.0 }, ], }; - assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS, false).is_err()); track.points[1].ele = Some(200.0); track.points[1].t = 0.0; - assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS, false).is_err()); } } diff --git a/host/obc-vectors/src/lib.rs b/host/obc-vectors/src/lib.rs index 0e9782e9f..0d445c5f4 100644 --- a/host/obc-vectors/src/lib.rs +++ b/host/obc-vectors/src/lib.rs @@ -212,7 +212,7 @@ pub fn ride_v6() -> Vec { v.push(142); // avg_hr v.push(176); // max_hr v.push(85); // avg_cad - v.push(0); // reserved + v.push(1); // flags: demo ride v.extend_from_slice(&le16(210)); // avg_pwr v.extend_from_slice(&le16(480)); // max_pwr v.extend_from_slice(&le32(756)); // energy kJ diff --git a/specs/Ride_Archive_Contract.md b/specs/Ride_Archive_Contract.md index 446b99769..ab42fb7fc 100644 --- a/specs/Ride_Archive_Contract.md +++ b/specs/Ride_Archive_Contract.md @@ -35,11 +35,12 @@ summary, all canonical samples, source record and local downloaded flag as one g Samples preserve timestamps, coordinates, optional elevation and sensor values, and segment starts. Wire bytes are not the library schema. -Each `rides//summary.json` is a version 3 manifest. It contains the canonical summary, +Each `rides//summary.json` is a version 4 manifest. It contains the canonical summary, its source, the downloaded flag, and the name, length and CRC of one immutable -`points-.json` file. That points file contains version 3 canonical point records. The manifest +`points-.json` file. That points file contains version 4 canonical point records. The manifest is the only publication point. A list reads manifests without reading track samples. A local summary edit keeps the source and points reference; it does not rewrite samples. +The summary's optional `isDemo` flag is false when absent. It does not change archive proof. The production persistence anchor is the existing `Library` directory inside the app container. The archive can create `Application Support/OBCLibrary` and its descendants below that anchor. diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 90780c092..5766c220d 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -205,7 +205,7 @@ The final 154 bytes are the summary footer: | 30 | 1 | average heart rate; `0xFF` = absent | | 31 | 1 | maximum heart rate; `0xFF` = absent | | 32 | 1 | average cadence; `0xFF` = absent | -| 33 | 1 | reserved, zero | +| 33 | 1 | flags; bit 0 is `demo`, all other bits are zero | | 34 | 2 | average power; `0xFFFF` = absent | | 36 | 2 | maximum power; `0xFFFF` = absent | | 38 | 4 | energy, kJ; `0xFFFF_FFFF` = absent | @@ -220,6 +220,9 @@ The final 154 bytes are the summary footer: | 151 | 1 | reserved, zero | | 152 | 2 | FTP limit, watts; `0` = not set | +- **Demo.** The factory sets bit 0 for a sample ride. The recorder writes zero. The app shows a + demo badge and excludes the ride from totals. Sync preserves the flag with the ride bytes. + A reader rejects any other flag bit. - **Climb and descent.** The device counts both with the same dead band, from the same altitude samples. - **Energy.** Each power sample adds its watts × the time since the previous sample, at most 2 s, @@ -249,7 +252,7 @@ The footer is last because the flat-store payload pages are write-once. A list r `object length == point_count × 20 + 154`. A reader rejects any other footer length. Finalize appends this footer and performs one store commit that publishes the final length and CRC and clears `RECORDING`. `specs/vectors/ride-v6.bin` pins three sample records — including sensor -sentinels and segment flags — and a footer on a trip day with both effort limits set. +sentinels and segment flags — and a footer on a trip day with both effort limits and the demo flag set. ### 7.3 `config` — the Config object diff --git a/specs/vectors/README.md b/specs/vectors/README.md index 47a5ca70c..19b5a1c7b 100644 --- a/specs/vectors/README.md +++ b/specs/vectors/README.md @@ -32,7 +32,7 @@ A drift on any side fails that side's tests — the files are the contract. | `eta.csv` | [`OBCR_Spec.md`](../OBCR_Spec.md) §1.2 estimate | one row per case: bike type, distance, ascent and the expected whole seconds, for every bike type. `obc-formats` and the `OBCKit` Swift tests read it | | `track-log.obct` | sample-codec vector (five complete 20-byte records, no header) | shaped for sensor/signed-coordinate coverage only; it is not accepted as a ride or recovery input | | `track-export.gpx` | GPX 1.1, `obc_route::track_to_gpx` | the export of finished `ride-v6.bin` as "Schauinsland & back" — the name's `&` pins XML escaping. Not spec-derived: the exporter's serialization *is* the contract, so this file is its output, and its value is cross-implementation | -| `ride-v6.bin` | ride object v6 (spec §7.2) | "Sensor Ride": three exact 20-byte recorded samples (including segment flags and mixed sensor presence), followed by the fixed 154-byte summary footer with 95 m of descent and 756 kJ. The ride started on day index 1 of 3 of the `trip-v3.bin` trip ("Alpen Traverse", key `0x0123_4567_89AB_CDEF`), on a Gravel bike, with max HR 185 and FTP 250 | +| `ride-v6.bin` | ride object v6 (spec §7.2) | "Sensor Ride": three exact 20-byte recorded samples (including segment flags and mixed sensor presence), followed by the fixed 154-byte summary footer with 95 m of descent and 756 kJ. The ride started on day index 1 of 3 of the `trip-v3.bin` trip ("Alpen Traverse", key `0x0123_4567_89AB_CDEF`), on a Gravel bike, with max HR 185, FTP 250 and the demo flag set | | `status-command-result.bin` | `status` msg 3 §4.3 | the answer to an accepted `installFw`: `cmd` 3, `ok`, `detail` 0. Pins the four-byte layout and the position of `detail` | | `command-set-clock.bin` | `setClock` §4.4 cmd 5 | `utc` 1783598400 (2026-07-09T12:00:00Z) · `offset_min` 120 | | `update-container-v1.bin` | OBCU container ([`OBCU_Spec.md`](../OBCU_Spec.md) §1), **unsigned/v1** | a full `UPDATE.BIN` / `fwImage` payload (§7.6, id 0): 64-byte header (`fw_version` `1.2.0+abc1234`, `image_len` 128) + a 128-byte raw image. Decoded by `obc-dfu` (`cargo test -p obc-dfu --test vectors`) and the iOS `OBCUHeader`. It is the shape of a fielded container and of the device-written rollback snapshot, and pairing it with the v2 file below pins the offset-compatibility guarantee across implementations | diff --git a/specs/vectors/ride-v6.bin b/specs/vectors/ride-v6.bin index 312145c75..1e9e13220 100644 Binary files a/specs/vectors/ride-v6.bin and b/specs/vectors/ride-v6.bin differ diff --git a/testing/suites.toml b/testing/suites.toml index 1fafe98a8..045903375 100644 --- a/testing/suites.toml +++ b/testing/suites.toml @@ -101,10 +101,15 @@ name = "obc-web-assemble" fixtures = true triggers = ["apps/obc-web-assemble/tests/fixture/**"] +# The factory ride is built from the app's bundled route. +[[package]] +name = "obc-replay" +triggers = ["companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx"] + # The board runs the host crate's detour and ride logic; no Cargo edge carries that. [[package]] name = "obc-host-core" -triggers = ["firmware/obc-fw-nrf54l/src/detour.rs", "firmware/obc-fw-nrf54l/src/flat_ride.rs", "firmware/obc-fw-nrf54l/src/demo_rides.rs"] +triggers = ["firmware/obc-fw-nrf54l/src/detour.rs", "firmware/obc-fw-nrf54l/src/flat_ride.rs", "firmware/obc-fw-nrf54l/src/factory_demo_ride.rs"] # The phone's router test reads the web builder's fixture; no Cargo edge carries that. [[package]] diff --git a/tools/check_fixture_policy.py b/tools/check_fixture_policy.py index 43efa85b3..99303282b 100644 --- a/tools/check_fixture_policy.py +++ b/tools/check_fixture_policy.py @@ -18,11 +18,12 @@ ALLOWED = { # Shipped wasm demo payload, not a developer fixture. "apps/obc-sim/assets/grimsel-demo.obcm", + # Bundled onboarding route and factory demo ride source. + "companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx", # Skin-preview product input/golden, owned and rendered by obc-bake. "host/obc-bake/assets/teningen-preview.obcm", # Authored source rides whose textual diffs remain reviewable. "fixtures/sources/sim-grimsel/tracks/grimsel-climb.gpx", - "companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx", } FIXTURE_SUFFIXES = ( ".obcm", diff --git a/tools/justfile b/tools/justfile index 89f6bec79..50989b7e4 100644 --- a/tools/justfile +++ b/tools/justfile @@ -107,11 +107,11 @@ format-card *args: cd "$OBC_ROOT" _run cargo run --release -p obc-usb --bin obc-format-card -- "$@" -# Build & flash the board. Opts (space-separated): debug-uart synth seed-rides build. +# Build & flash the board. Opts (space-separated): debug-uart synth factory-demo-ride build. # The radio and the USB plane are in every image (#889, #1530) — there is nothing to turn on. # e.g. obc flash (real sensors) obc flash debug-uart # obc flash build (compile only, no flash) -# Build & flash the board. Opts: debug-uart synth seed-rides build (e.g. obc flash seed-rides) +# Build & flash the board. Opts: debug-uart synth factory-demo-ride build (e.g. obc flash factory-demo-ride) [group('device')] flash *args: #!/usr/bin/env bash @@ -121,17 +121,24 @@ flash *args: for o in "$@"; do case "$o" in debug-uart|du) du=1 ;; synth) synth=1 ;; - seed-rides) seed=1 ;; + factory-demo-ride) seed=1 ;; build|build-only) action=build ;; - *) _err "unknown flash option: '$o' (want: debug-uart synth seed-rides build)"; exit 1 ;; + *) _err "unknown flash option: '$o' (want: debug-uart synth factory-demo-ride build)"; exit 1 ;; esac; done [[ "$action" == run ]] && { ensure_probe || exit 1; } a=(); feats=() (( du )) && feats+=(debug-uart) (( synth )) && feats+=(synth) if (( seed )); then - feats+=(seed-rides) - export OBC_DEMO_RIDE_START="$(python3 -c 'import time; print(int(time.time()) - 3 * 86400)')" + feats+=(factory-demo-ride) + if [[ -z "${OBC_DEMO_RIDE_FILE:-}" ]]; then + export OBC_DEMO_RIDE_FILE="$OBC_ROOT/target/factory/grimsel-pass.obcr" + start="$(python3 -c 'from datetime import datetime, timezone; print(int(datetime(datetime.now(timezone.utc).year, 1, 1, 9, tzinfo=timezone.utc).timestamp()))')" + cd "$OBC_ROOT" + _run cargo run --release -p obc-replay --example gpx_to_ride -- \ + "$OBC_ROOT/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx" \ + "$OBC_DEMO_RIDE_FILE" "$start" "Grimsel Pass" --demo + fi fi (( ${#feats[@]} )) && a+=(--features "$(IFS=,; echo "${feats[*]}")") cd "$OBC_ROOT/firmware/obc-fw-nrf54l"