diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e32fc90..5f0ce76 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -22,6 +22,19 @@ foreground reconnect and interrupted-ride reset work until the demo is closed. The demo therefore checks product navigation and local gear behavior only; it does not add any physical-hardware evidence. +Build 1.0 (17) exposed a real-device discovery regression on 2026-08-21: Settings +found one original Zwift Click but remained on "Checking for others" and never +saved it, so the Ready to shift screen correctly had no configured Click status +to show. The discovery deadline had been owned by a SwiftUI change callback that +could miss scanning starting during first appearance. It is now keyed to both +the service's scan generation and current scanning state, including scans that +start before the view's change observer is installed. Startup now runs Click and +Headwind discovery independently and applies the trainer's tested selection +rule to both: one result connects automatically, while multiple results are not +guessed. The corrected development build was installed over USB on the same +iPhone 17 Pro that exposed the regression; after waking the original Click with +a button press, it was saved, connected automatically and appeared in the app. + ## Build and test Run the hardware-independent test suite: diff --git a/VirtualGears.xcodeproj/project.pbxproj b/VirtualGears.xcodeproj/project.pbxproj index 0bac01f..44dd9ac 100644 --- a/VirtualGears.xcodeproj/project.pbxproj +++ b/VirtualGears.xcodeproj/project.pbxproj @@ -320,7 +320,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; @@ -345,7 +345,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; diff --git a/VirtualGearsProduct/ClickCentralService.swift b/VirtualGearsProduct/ClickCentralService.swift index fe2c6e1..6a47e41 100644 --- a/VirtualGearsProduct/ClickCentralService.swift +++ b/VirtualGearsProduct/ClickCentralService.swift @@ -97,6 +97,9 @@ final class ClickCentralService: NSObject { private var repeatTask: Task? private var isSuspendedForDemo = false private var resumesAfterDemoDisconnect = false +#if DEBUG + private var usesStagedScan = false +#endif private var edgeTracker = ZwiftClickEdgeTracker() private var heldButton: ZwiftClickButton? private var isHolding = false @@ -114,6 +117,13 @@ final class ClickCentralService: NSObject { func startScanning() { guard !isSuspendedForDemo else { return } +#if DEBUG + if usesStagedScan { + scanGeneration += 1 + state = .scanning + return + } +#endif desiredConnection = false reconnectTask?.cancel() scanWhenPoweredOn = true @@ -550,6 +560,7 @@ extension ClickCentralService { self.batteryLevel = batteryLevel connectionIsStalled = stalled identificationCandidateID = identifying + usesStagedScan = state == .scanning self.state = state } diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift index c122c6a..386ae04 100644 --- a/VirtualGearsProduct/SetupView.swift +++ b/VirtualGearsProduct/SetupView.swift @@ -519,6 +519,7 @@ private struct TrainerSetupView: View { candidates: kickr.candidates, selectedID: kickr.selectedID, isScanning: kickr.isScanning, + scanGeneration: kickr.scanGeneration, connectionState: kickr.state, initialPhase: stagedDiscoveryPhase(for: .trainer), startScanning: kickr.startScanning, @@ -606,6 +607,7 @@ private struct ShiftingSetupView: View { candidates: click.candidates, selectedID: click.selectedID, isScanning: click.isScanning, + scanGeneration: click.scanGeneration, connectionState: click.state, initialPhase: stagedDiscoveryPhase(for: .click), startScanning: click.startScanning, @@ -740,6 +742,7 @@ private struct HeadwindSetupView: View { candidates: headwind.candidates, selectedID: headwind.selectedID, isScanning: headwind.state == .scanning, + scanGeneration: headwind.scanGeneration, connectionState: headwind.state, initialPhase: stagedDiscoveryPhase(for: .headwind), startScanning: headwind.startScanning, @@ -1656,6 +1659,7 @@ private struct DeviceDiscoverySection: View { let candidates: [BluetoothCandidate] let selectedID: UUID? let isScanning: Bool + let scanGeneration: Int let connectionState: ProductConnectionState let startScanning: () -> Void let stopScanning: () -> Void @@ -1666,8 +1670,6 @@ private struct DeviceDiscoverySection: View { let select: (BluetoothCandidate) -> Void @State private var discovery = DeviceDiscoveryState() - @State private var timeoutTask: Task? - @State private var timeoutScheduled = false private let searchDuration = DeviceDiscoveryPolicy.searchDuration @@ -1679,6 +1681,7 @@ private struct DeviceDiscoverySection: View { candidates: [BluetoothCandidate], selectedID: UUID?, isScanning: Bool, + scanGeneration: Int, connectionState: ProductConnectionState, initialPhase: DeviceDiscoveryState.Phase = .idle, startScanning: @escaping () -> Void, @@ -1696,6 +1699,7 @@ private struct DeviceDiscoverySection: View { self.candidates = candidates self.selectedID = selectedID self.isScanning = isScanning + self.scanGeneration = scanGeneration self.connectionState = connectionState var initialDiscovery = DeviceDiscoveryState() switch initialPhase { @@ -1801,8 +1805,18 @@ private struct DeviceDiscoverySection: View { beginSearch() } } - .onChange(of: isScanning) { _, scanning in - if scanning { scheduleTimeoutIfNeeded() } + .task(id: DiscoveryClock( + scanGeneration: scanGeneration, + isScanning: isScanning + )) { + guard isScanning else { return } + do { + try await Task.sleep(for: searchDuration) + } catch { + return + } + guard !Task.isCancelled, isScanning else { return } + finishSearch() } .onChange(of: candidates.count) { _, count in discovery.observe(candidateCount: count) @@ -1812,13 +1826,10 @@ private struct DeviceDiscoverySection: View { } .onChange(of: hasSavedDevice) { _, saved in if saved { - timeoutTask?.cancel() - timeoutScheduled = false discovery.reset() } } .onDisappear { - timeoutTask?.cancel() if discovery.phase != .idle || isScanning { cancelScanning() } @@ -1846,33 +1857,14 @@ private struct DeviceDiscoverySection: View { } private func beginSearch() { - timeoutTask?.cancel() - timeoutScheduled = false discovery.start() startScanning() - if isScanning { - scheduleTimeoutIfNeeded() - } else { + if !isScanning { handleConnectionState(connectionState) } } - private func scheduleTimeoutIfNeeded() { - guard !timeoutScheduled else { return } - timeoutScheduled = true - timeoutTask = Task { @MainActor in - do { - try await Task.sleep(for: searchDuration) - } catch { - return - } - guard !Task.isCancelled else { return } - finishSearch() - } - } - private func finishSearch() { - timeoutScheduled = false stopScanning() if candidates.count == 1, let candidate = candidates.first, candidate.compatibility.isUsable { @@ -1888,19 +1880,20 @@ private struct DeviceDiscoverySection: View { !isScanning else { return } if case let .unavailable(reason) = state, !reason.localizedCaseInsensitiveContains("starting") { - timeoutTask?.cancel() - timeoutScheduled = false discovery.finish(candidateCount: candidates.count) } } private func choose(_ candidate: BluetoothCandidate) { - timeoutTask?.cancel() - timeoutScheduled = false select(candidate) } } +private struct DiscoveryClock: Equatable { + let scanGeneration: Int + let isScanning: Bool +} + private struct CandidateRow: View { let candidate: BluetoothCandidate let selected: Bool diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift index 8ac73b0..4c4c6b4 100644 --- a/VirtualGearsProduct/VirtualGearsApp.swift +++ b/VirtualGearsProduct/VirtualGearsApp.swift @@ -119,6 +119,7 @@ enum ScreenshotFixture: String { case settingsBluetoothIssue = "-shotSettingsBluetoothIssue" case settingsStalled = "-shotSettingsStalled" case settingsClickLowBattery = "-shotSettingsClickLowBattery" + case settingsClickSingleCandidate = "-shotSettingsClickSingleCandidate" case settingsClickDuplicates = "-shotSettingsClickDuplicates" case settingsClickIdentifying = "-shotSettingsClickIdentifying" case settingsUnsafeGears = "-shotSettingsUnsafeGears" @@ -187,7 +188,8 @@ private struct ScreenshotFixtureView: View { .settingsUnsupported, .settingsTimedOut, .settingsBluetoothIssue, .settingsStalled, .settingsClickLowBattery, .settingsClickDuplicates, - .settingsClickIdentifying, .settingsUnsafeGears, + .settingsClickSingleCandidate, .settingsClickIdentifying, + .settingsUnsafeGears, .settingsAccessibility: NavigationStack { SetupView( @@ -244,10 +246,12 @@ private struct ScreenshotFixtureView: View { id: ScreenshotFixture.kickrID ) } - configuration.rememberClick( - named: "Zwift Click", - id: ScreenshotFixture.clickID - ) + if scenario != .settingsClickSingleCandidate { + configuration.rememberClick( + named: "Zwift Click", + id: ScreenshotFixture.clickID + ) + } configuration.rememberHeadwind( named: "KICKR HEADWIND 4D21", id: ScreenshotFixture.headwindID @@ -325,11 +329,18 @@ private struct ScreenshotFixtureView: View { name: "Zwift Click" ), ] + let clickCandidates = scenario == .settingsClickSingleCandidate + ? [BluetoothCandidate( + id: ScreenshotFixture.clickID, + name: "Zwift Click" + )] + : (scenario == .settingsClickDuplicates + || scenario == .settingsClickIdentifying ? duplicateClicks : []) click.stageScreenshot( name: configuration.clickName, batteryLevel: scenario == .settingsClickLowBattery ? 15 : 82, - candidates: scenario == .settingsClickDuplicates - || scenario == .settingsClickIdentifying ? duplicateClicks : [], + candidates: clickCandidates, + state: scenario == .settingsClickSingleCandidate ? .scanning : .ready, identifying: scenario == .settingsClickIdentifying ? ScreenshotFixture.clickID : nil ) diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index f747059..bac5e8e 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -78,58 +78,75 @@ struct VirtualGearsHomeView: View { let needsClick = !store.configuration.usesClick let needsHeadwind = !store.configuration.usesHeadwind - needsClick ? click.startScanning() : click.autoConnectSavedDevice() - needsHeadwind - ? headwind.startScanning() : headwind.autoConnectSavedDevice() - guard needsClick || needsHeadwind else { return } + async let clickDiscovery: Void = discoverClick(ifNeeded: needsClick) + async let headwindDiscovery: Void = discoverHeadwind(ifNeeded: needsHeadwind) + _ = await (clickDiscovery, headwindDiscovery) + } - // The first Bluetooth permission prompt can outlive the view's initial - // task turn. Start the discovery window only after scanning really began. + private func waitUntilScanning( + _ isScanning: @escaping () -> Bool + ) async -> Bool { for _ in 0..<300 { - let clickStarted = !needsClick || click.isScanning - let headwindStarted = !needsHeadwind || headwind.isScanning - if clickStarted && headwindStarted { break } + if isScanning() { return true } do { try await Task.sleep(for: .milliseconds(100)) } catch { - return + return false } } - guard !Task.isCancelled else { return } - let clickScanGeneration = click.scanGeneration - let headwindScanGeneration = headwind.scanGeneration + return false + } + + private func discoverClick(ifNeeded needed: Bool) async { + guard needed else { + click.autoConnectSavedDevice() + return + } + click.startScanning() + guard await waitUntilScanning({ click.isScanning }) else { return } + let generation = click.scanGeneration do { try await Task.sleep(for: DeviceDiscoveryPolicy.searchDuration) } catch { return } - - if needsClick, !store.configuration.usesClick, - click.scanGeneration == clickScanGeneration { - if click.candidates.count == 1, let candidate = click.candidates.first { - store.configuration.rememberClick( - named: candidate.name, - id: candidate.id - ) - click.selectAndConnect(candidate.id) - } else { - click.stopScanning(reconnectSavedDevice: false) - } + guard !store.configuration.usesClick, + click.scanGeneration == generation else { return } + let seen = click.candidates.map { DiscoveredTrainer(id: $0.id) } + guard case let .connect(id) = TrainerPicker.choice(from: seen), + let candidate = click.candidates.first(where: { $0.id == id }) + else { + click.stopScanning(reconnectSavedDevice: false) + return } + store.configuration.rememberClick(named: candidate.name, id: candidate.id) + click.selectAndConnect(candidate.id) + } - if needsHeadwind, !store.configuration.usesHeadwind, - headwind.scanGeneration == headwindScanGeneration { - if headwind.candidates.count == 1, - let candidate = headwind.candidates.first { - store.configuration.rememberHeadwind( - named: candidate.name, - id: candidate.id - ) - headwind.selectAndConnect(candidate.id) - } else { - headwind.stopScanning(reconnectSavedDevice: false) - } + private func discoverHeadwind(ifNeeded needed: Bool) async { + guard needed else { + headwind.autoConnectSavedDevice() + return + } + headwind.startScanning() + guard await waitUntilScanning({ headwind.state == .scanning }) else { return } + let generation = headwind.scanGeneration + do { + try await Task.sleep(for: DeviceDiscoveryPolicy.searchDuration) + } catch { + return + } + guard !store.configuration.usesHeadwind, + headwind.scanGeneration == generation else { return } + let seen = headwind.candidates.map { DiscoveredTrainer(id: $0.id) } + guard case let .connect(id) = TrainerPicker.choice(from: seen), + let candidate = headwind.candidates.first(where: { $0.id == id }) + else { + headwind.stopScanning(reconnectSavedDevice: false) + return } + store.configuration.rememberHeadwind(named: candidate.name, id: candidate.id) + headwind.selectAndConnect(candidate.id) } } diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift index db2b251..db2902a 100644 --- a/VirtualGearsUITests/VirtualGearsUITests.swift +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -179,6 +179,17 @@ final class VirtualGearsUITests: XCTestCase { XCTAssertEqual(ridingApp.label, "Riding app, Waiting for connection") } + func testSingleDiscoveredClickIsSavedInsteadOfCheckingForever() { + launch("-shotSettingsClickSingleCandidate") + assertVisible("screen.settings") + app.staticTexts["Zwift Click"].firstMatch.tap() + + XCTAssertTrue( + app.staticTexts["Your Click"].waitForExistence(timeout: 12), + "A sole Click was not selected when the discovery window ended" + ) + } + func testRideShowsAllStatusIconsAndPrimaryControls() { launch("-shotRide") diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md index c1953eb..1af2afa 100644 --- a/docs/APP_STORE.md +++ b/docs/APP_STORE.md @@ -298,7 +298,7 @@ For each update, raise `MARKETING_VERSION` (1.0 → 1.1) and upload again. `CURRENT_PROJECT_VERSION` must increase on every single upload, even a re-upload of the same version. -The current TestFlight build is 1.0 (15). Build 5 added the Demo Mode that shows +The current TestFlight build is 1.0 (18). Build 5 added the Demo Mode that shows the wheel size and command bytes changing. Build 6 removed a wheel-size limit that was never real: a physical KICKR V5 accepts every value the command can express, so the app now states the range of riding-app wheel sizes it supports @@ -375,6 +375,20 @@ white panels behind the setup wizard's main actions, groups physical chainring choices into numerically ordered one-ring and two-ring lists, and removes the redundant parked-chain reminder from the startup screen. +Physical testing of build 17 found that a newly discovered sole Zwift Click +could remain on "Found one. Checking for others..." because the Settings view +could miss the moment Bluetooth scanning began and therefore never start its +eight-second selection window. Startup also waited for Click and Headwind +discovery together, so one accessory failing to start scanning could delay the +other. Build 1.0 (18) was uploaded to TestFlight on 21 August 2026. It gives each +accessory its own discovery window and uses +the trainer's existing rule: one result is saved and connected automatically; +more than one requires a choice. Settings ties its window to the scan generation +itself, and a UI regression test waits for one discovered Click to become the +saved Click. The corrected development build was physically checked on the +iPhone 17 Pro with the original Click that exposed the regression. Build 17 +should not be submitted for App Review; use build 18. + The live App Store description still carries the old "starts the session" sentence. It is corrected in this file and needs the same edit in App Store Connect on the next metadata change. diff --git a/docs/DEMO_VIDEO.md b/docs/DEMO_VIDEO.md index 0e75982..7417ffe 100644 --- a/docs/DEMO_VIDEO.md +++ b/docs/DEMO_VIDEO.md @@ -40,7 +40,8 @@ user-generated content — do not exist in this app. There is nothing to film. **Delete the app from the phone first.** Deleting it takes its Bluetooth permission with it, so the prompt appears again on the next launch, and the app starts with no remembered trainer — exactly what an App Review device sees. -Then install 1.0 (6) again from TestFlight, but **do not open it**. The +Then install the latest TestFlight build, currently 1.0 (18), but **do not open +it**. The recording has to start from the Home screen. Also: @@ -172,4 +173,5 @@ video: > > No hardware is needed to review the app: tap "Try Demo" on the first screen. -The build attached to the version is now 1.0 (6), not the rejected 1.0 (5). +Attach build 1.0 (18), not the rejected 1.0 (5), to the version before +resubmitting.