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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,11 @@ struct WatchPresetListView: View {
// Update coordinator's active sync service and correlation ID
// when timer is presented/dismissed
if let timer = newValue {
messageCoordinator.isTimerPresented = true
messageCoordinator.activeSyncService = syncService
messageCoordinator.activeCorrelationID = timer.correlationID
} else {
messageCoordinator.isTimerPresented = false
messageCoordinator.activeSyncService = nil
messageCoordinator.activeCorrelationID = nil
syncService = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,14 @@ struct WatchWorkoutCoordinator {
} else {
let log = config.makeWorkoutLog(healthKitUUID)
modelContext.insert(log)
try? modelContext.save()
// Surface a save failure: this is the only SwiftData record of a
// standalone workout, saved just as watchOS is most likely to suspend
// the app. Swallowing the error lost the workout with no diagnostics.
do {
try modelContext.save()
} catch {
Logger.workoutLogging.error("Failed to save standalone Watch workout log: \(error.localizedDescription)")
}
}

Logger.healthKit.info("Watch \(config.timerKind.rawValue) workout completed, HealthKit UUID: \(healthKitUUID?.uuidString ?? "none"), displayOnly: \(config.displayOnly), avgHR: \(averageHeartRate.map { String(format: "%.0f", $0) } ?? "none")")
Expand Down
67 changes: 56 additions & 11 deletions Kraftli Timers Watch App/Services/WatchMessageCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ final class WatchMessageCoordinator {
/// the active timer (or with nil correlation) are applied.
var activeCorrelationID: UUID?

/// Whether *any* timer view is currently on screen — set for both iPhone-led
/// and standalone (wrist-started) timers. Standalone timers register no sync
/// service and carry no correlation ID, so this flag is the only way to tell
/// "standalone timer presented" apart from "nothing presented" — without it a
/// stale iPhone-led stop would reach `handleOrphanedStop` and end a live
/// standalone workout (issue #46, finding 7).
var isTimerPresented: Bool = false

/// Reference to the workout session manager for orphaned stop handling.
/// Set by the app entry point after both coordinator and manager are created.
var sessionManager: WorkoutSessionManager?
Expand Down Expand Up @@ -133,11 +141,44 @@ final class WatchMessageCoordinator {

// MARK: - Stop Timer Handling

/// How an inbound `StopTimerMessage` should be dispatched. Pure routing
/// decision — see `routeStop`.
enum StopRouting: Equatable {
/// No timer view at all — end any orphaned HKWorkoutSession left running.
case orphanedCleanup
/// A standalone (wrist-started) timer is on screen — never stop it from a
/// remote message (the iPhone has no correlation for a standalone workout).
case ignoreStandalone
/// An iPhone-led timer is on screen but the stop targets a different
/// workout — stale, ignore.
case ignoreStaleMismatch
/// An iPhone-led timer is on screen and the stop matches — forward it.
case forward
}

/// Decides how to route an inbound stop. Pure (no side effects, no HealthKit)
/// so the staleness/standalone-protection logic is unit-testable directly.
static func routeStop(
isTimerPresented: Bool,
hasSyncService: Bool,
activeCorrelationID: UUID?,
stopCorrelationID: UUID?
) -> StopRouting {
guard isTimerPresented else { return .orphanedCleanup }
guard hasSyncService else { return .ignoreStandalone }
if stopCorrelationID == nil || stopCorrelationID == activeCorrelationID {
return .forward
}
return .ignoreStaleMismatch
}

/// Handles a reliable stop message from iPhone.
///
/// This arrives via the application context (persisted delivery) and/or
/// `sendMessage` (immediate delivery). Correlation ID matching prevents stale stops from
/// killing a new timer that started after the original one completed.
/// killing a new timer that started after the original one completed, and the
/// presence flag prevents a stale stop from ending a live *standalone* workout
/// (issue #46, finding 7).
@MainActor
private func handleStopTimerMessage(_ message: StopTimerMessage) {
// Deduplicate within a short window (stop may arrive via two delivery
Expand All @@ -157,18 +198,22 @@ final class WatchMessageCoordinator {
stoppedCorrelationIDs.insert(stoppedID)
}

if let syncService = activeSyncService {
// Timer view is showing — check correlation before forwarding
if message.correlationID == nil || message.correlationID == activeCorrelationID {
Logger.timerSync.info("StopTimerMessage matches active timer, forwarding stop")
syncService.handleControlMessage(TimerControlMessage(action: .stop))
} else {
Logger.timerSync.info("StopTimerMessage correlationID mismatch (stop: \(message.correlationID?.uuidString ?? "nil"), active: \(self.activeCorrelationID?.uuidString ?? "nil")), ignoring stale stop")
}
} else {
// No timer view showing — handle as orphaned stop
switch Self.routeStop(
isTimerPresented: isTimerPresented,
hasSyncService: activeSyncService != nil,
activeCorrelationID: activeCorrelationID,
stopCorrelationID: message.correlationID
) {
case .orphanedCleanup:
Logger.timerSync.info("StopTimerMessage received with no active timer, cleaning up orphaned session")
handleOrphanedStop()
case .ignoreStandalone:
Logger.timerSync.info("StopTimerMessage received while a standalone timer is active, ignoring (no correlation for wrist-started workouts)")
case .ignoreStaleMismatch:
Logger.timerSync.info("StopTimerMessage correlationID mismatch (stop: \(message.correlationID?.uuidString ?? "nil"), active: \(self.activeCorrelationID?.uuidString ?? "nil")), ignoring stale stop")
case .forward:
Logger.timerSync.info("StopTimerMessage matches active timer, forwarding stop")
activeSyncService?.handleControlMessage(TimerControlMessage(action: .stop))
}
}

Expand Down
63 changes: 63 additions & 0 deletions Kraftli Timers Watch AppTests/WatchMessageCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ struct WatchMessageCoordinatorTests {
@Test @MainActor func twoDistinctStops_bothApply() {
let coordinator = WatchMessageCoordinator()
let syncService = DefaultWatchTimerSyncService()
coordinator.isTimerPresented = true
coordinator.activeSyncService = syncService

var stopCount = 0
Expand All @@ -115,6 +116,7 @@ struct WatchMessageCoordinatorTests {
@Test @MainActor func duplicateStop_sameCorrelationID_appliesOnce() {
let coordinator = WatchMessageCoordinator()
let syncService = DefaultWatchTimerSyncService()
coordinator.isTimerPresented = true
coordinator.activeSyncService = syncService

var stopCount = 0
Expand All @@ -135,6 +137,7 @@ struct WatchMessageCoordinatorTests {
@Test @MainActor func stop_mismatchedCorrelationID_isIgnored() {
let coordinator = WatchMessageCoordinator()
let syncService = DefaultWatchTimerSyncService()
coordinator.isTimerPresented = true
coordinator.activeSyncService = syncService
coordinator.activeCorrelationID = UUID()

Expand All @@ -149,4 +152,64 @@ struct WatchMessageCoordinatorTests {

#expect(stopCount == 0)
}

// MARK: - Stop routing (pure decision, issue #46 finding 7)

/// The regression: a standalone timer is on screen (no sync service, no
/// correlation) and a stale iPhone-led stop arrives. It must be ignored, NOT
/// routed to orphaned cleanup — which previously ended the live HK session.
@Test func routeStop_standalonePresented_ignoresForeignStop() {
let routing = WatchMessageCoordinator.routeStop(
isTimerPresented: true,
hasSyncService: false,
activeCorrelationID: nil,
stopCorrelationID: UUID()
)
#expect(routing == .ignoreStandalone)
}

/// No timer on screen → end any orphaned HK session.
@Test func routeStop_noTimer_orphanedCleanup() {
let routing = WatchMessageCoordinator.routeStop(
isTimerPresented: false,
hasSyncService: false,
activeCorrelationID: nil,
stopCorrelationID: UUID()
)
#expect(routing == .orphanedCleanup)
}

/// iPhone-led timer on screen, stop matches the active correlation → forward.
@Test func routeStop_iPhoneLedMatching_forwards() {
let id = UUID()
let routing = WatchMessageCoordinator.routeStop(
isTimerPresented: true,
hasSyncService: true,
activeCorrelationID: id,
stopCorrelationID: id
)
#expect(routing == .forward)
}

/// A nil-correlation stop applies to the active iPhone-led timer.
@Test func routeStop_iPhoneLedNilCorrelation_forwards() {
let routing = WatchMessageCoordinator.routeStop(
isTimerPresented: true,
hasSyncService: true,
activeCorrelationID: UUID(),
stopCorrelationID: nil
)
#expect(routing == .forward)
}

/// iPhone-led timer on screen, stop targets a different workout → stale.
@Test func routeStop_iPhoneLedMismatch_stale() {
let routing = WatchMessageCoordinator.routeStop(
isTimerPresented: true,
hasSyncService: true,
activeCorrelationID: UUID(),
stopCorrelationID: UUID()
)
#expect(routing == .ignoreStaleMismatch)
}
}
Loading