Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

One Trip, Whoever Guides

~15 min · swift, protocols, concurrency, architecture, navigation

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Two engines, one trip

Lesson four left the app with two kinds of guidance. For Apple's and Kakao's routes, Pathfinder's own GuidanceEngine matches fixes to the line and decides every word. For Google's routes, Google's engine guides and Pathfinder follows its feed. The obvious way to support both is an if in every screen. The way Pathfinder chose is one more protocol: GuidedTrip.

GuidedTrip has two children. NavigationSession is the trip Pathfinder's own engine guides. FeedGuidedTrip follows an engine that guides itself, through FeedTrip in PathfinderKit, which turns feed readings into the same GuidanceEvents the engine emits, so the stages, the chaining and the lines stay one set of rules under test. The choice between them is made in exactly one place, when guidance starts: a provider that conforms to SelfGuidingProvider makes its own trip, and every other provider's route becomes a NavigationSession. The architecture's phrasing is the rule to remember: a new way to guide is a new child, never a branch in a screen.

Everything reads TripStatus

Each trip publishes one neutral value, TripStatus: the phase, whether it is rerouting, the maneuver ahead (kind, words, distance), what is left in metres and seconds, speed, and how far off the route Dad is while off it. The banner reads it. The bottom bar reads it. The watch glance is built from it. Pippa's report is built from it for both kinds of trip. The CarPlay scene reads it too, though CarPlay is honest scaffolding for now: Dad's present car does not support it, the entitlement is not in the phone's signing, and nothing has run in a car. None of these readers knows which engine is behind the value.

Where the two children genuinely differ, the protocol says so in a property rather than in a type check:

  • drawnRoute is the session's route, and nil for a feed trip, because Google's map draws and keeps the geometry.
  • showsOwnManeuver is true for a feed trip, so the app draws no banner over Google's header.
  • camerasOnRoute is empty for a feed trip: the bundled speed cameras are Korea's.
  • providerVoice(allowed:) has a default that does nothing. Only the feed trip overrides it, to silence Google's own voice while Dad has muted the app or a talk with Pippa holds the microphone.

The feed trip never holds Google's SDK. It holds a FeedNavigator, a small protocol the engine conforms to, so the suite drives a FeedGuidedTrip with a scripted feed on a Mac. One gotcha came with that seam: the navigator calls back into its trip through [weak self] closures, so something must hold the trip strongly. The app model does. A test that builds a trip and drops it sees no events at all.

A reroute stays in the trip's own mode

A code review of 2026-10-02 found a walk that left its path coming back as a driving route. The reroute had asked the settings' default mode, not the trip's. The session then guided a pedestrian with car distances and speed-camera warnings. Now reroute reads route.mode, asks with alternatives: false, and refuses a reply from another provider or in another mode as mismatchedProvider. A failure says "길을 못 찾았어" once, then tries again every 15 seconds while still off route; a quota, missing-credentials, permission, unsupported or mismatch failure stops trying, because asking again will not change the answer. The ETA refresh follows the same rule and asks with the route's own mode.

On a Google trip, Google reroutes itself. FeedTrip turns its "rerouting" state into the same off-route and rerouted events, and when Google quietly swaps to a better route that leaves the same maneuver ahead (same kind, distance within max(50 m, 20%)), the band already spoken is not said again.

Arrival ends once

The same review found the arrival bug. The app used to schedule an end on every fix after arrival, and a late one could end the next drive or clear a new preview. Now scheduleArrivalEnd runs once per arrival: it refuses if a task is already pending, waits eight seconds, and ends guidance only if the trip on screen is still the same object that arrived (self.trip === arrived). The trigger reads trip.status.phase == .arrived, so it works the same for Pathfinder's engine and for Google's. (Track 8 adds one exception: at a large building's door, the arrival can be held while the barometer says Dad is on the wrong level.)

Let the variation live in one constructor. When two engines must coexist, make them produce the same value and decide between them once, where the thing is made. Every reader downstream then has one shape to read, and the next engine is an addition, not a hunt through the screens.

Code

PathfinderMobile, Navigation/GuidedTrip.swift: one trip, whoever guides it (verbatim excerpt)·swift
@MainActor
protocol GuidedTrip: AnyObject {
  var destination: Place { get }
  var mode: TravelMode { get }
  var startedAt: Date { get }
  var ended: Bool { get }
  /// The neutral state every surface reads.
  var status: TripStatus { get }
  /// What Pippa is told beside the state.
  var summary: PippaNavigationReport.Trip { get }
  /// The route as the app itself draws it; nil when the provider's own map
  /// draws it and keeps the geometry.
  var drawnRoute: NavRoute? { get }
  /// The provider's own screen shows the maneuver ahead, so the app draws no
  /// banner of its own over it.
  var showsOwnManeuver: Bool { get }
  var camerasOnRoute: [CameraOnRoute] { get }
  var nextCamera: CameraOnRoute? { get }
  var cameraAlert: CameraAlert? { get }
  /// One position sample from the app's own feed (or the simulation's).
  func handle(_ sample: LocationSample)
  func end(reason: String)
  /// Pippa hears about the trip now, whatever the interval says.
  func reportNow()
  /// Whether a provider's own voice may sound right now. It never may while
  /// Dad has the voice muted or a talk with Pippa is open: that voice plays
  /// outside Pathfinder's player, so nothing else would silence it or keep
  /// the talk's microphone from hearing it.
  func providerVoice(allowed: Bool)
}

extension GuidedTrip {
  /// A trip Pathfinder's own engine guides has no other voice to gate.
  func providerVoice(allowed: Bool) {}
}
PathfinderMobile, App/AppModel.swift: the one place the engine is chosen, and arrival that ends once (two verbatim excerpts)·swift
    // Whoever guides makes the trip: the provider's own engine when it has
    // one, Pathfinder's otherwise.
    let made: (any GuidedTrip)? =
      if let guiding = provider as? any SelfGuidingProvider {
        guiding.makeTrip(for: route, fromHandoff: fromHandoff, dependencies: dependencies())
      } else {
        NavigationSession(route: route, fromHandoff: fromHandoff, dependencies: dependencies())
      }
// ...
  /// Arrival ends the drive a moment later, once, and only the drive that
  /// arrived. It used to schedule an end on every fix after arrival, and a
  /// late one ended the NEXT drive or cleared a new preview (code review
  /// 2026-10-02).
  private func scheduleArrivalEnd(for arrived: any GuidedTrip) {
    guard arrivalTask == nil else { return }
    arrivalTask = Task { @MainActor [weak self, weak arrived] in
      try? await Task.sleep(for: .seconds(8))
      guard !Task.isCancelled, let self, let arrived, self.trip === arrived else { return }
      self.arrivalTask = nil
      self.endGuidance(reason: "arrived")
    }
  }
PathfinderMobile, Navigation/NavigationSession.swift: a reroute asks in the trip's own mode (verbatim excerpt, cut after the new route is adopted)·swift
  private func reroute(from sample: LocationSample) {
    guard rerouteTask == nil, !ended else { return }
    rerouting = true
    let options = deps.routeOptions()
    let provider = deps.provider
    let destination = self.destination
    // The drive's own mode, never the settings default: a walk that left its
    // path once came back as a DRIVING route (code review 2026-10-02), and
    // the session then guided a pedestrian with car distances and cameras.
    let mode = route.mode
    rerouteTask = Task { [weak self] in
      do {
        let fresh = try await provider.routes(
          from: sample.coordinate, heading: sample.course, to: destination,
          options: RouteOptions(
            alternatives: false, avoidTolls: options.avoidTolls,
            avoidHighways: options.avoidHighways, mode: mode))
        guard let self, !Task.isCancelled else { return }
        guard let best = fresh.first else { throw MapsIssue.noRoute }
        guard best.providerID == self.route.providerID, best.mode == mode else {
          throw MapsIssue.mismatchedProvider
        }
        self.adopt(best)

External links

Exercise

Find a feature in your own code that has two or more interchangeable engines behind it (a local and a remote search, two payment flows, a cached and a live data source). List every place outside the engines that checks which one is active. Design a protocol whose read side is a single neutral status value and whose differences are properties ("draws its own banner", "has its own voice"), make each engine a child, and move the choice into one constructor. Then find one delayed callback in that feature and make it compare identity with === before it acts.
Hint
Start from the readers, not the engines: write down exactly what each screen reads, and the union of those fields is your status value. If a reader needs something only one engine has, that is a property with a sensible default on the protocol, the way providerVoice(allowed:) does nothing for Pathfinder's own trips.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.