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

Google Guides Itself

~17 min · swift, navigation-sdk, licensing, sentinel-values, maps

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

The terms decide the architecture

Abroad, Google is the main map. That raised a question no amount of clean code could answer: may Pathfinder guide a Google route in Pippa's voice? Google's Maps Platform terms, §3.2.3 ("Restrictions Against Misusing the Services"), say no in three places. Customers will not "use Google Maps Content with text-to-speech services"; will not "convert text-based driving times into synthesized speech results"; and will not use the core services "to create a substitute of the Google Maps Core Services, Google Maps, or Google Maps mobile apps, or their features". Pippa's voice is synthesized speech, and Pathfinder's guidance engine walking a Google route would be a substitute navigator built from Google's directions.

So Dad ruled on 2026-10-03: "공식 Navigation SDK 로 가", go with the official Navigation SDK. Google's own engine finds, draws and guides the route inside the app, and Pathfinder follows. Google's child declares exactly that: ProviderTraits(guide: .provider, routeIsPortable: false, regions: [.overseas]). What follows is Pathfinder's own reading of those terms, recorded in its architecture as invariant 19. It is a design decision, not legal advice, and it shaped every line below.

What crosses the line, and what never does

GoogleEngine is the only place in the app where a Google route is found, drawn and guided. What leaves it is neutral. RouteFacts carries a length, a time and the line's points, used only to frame the camera on Google's own map. FeedReading carries the turn feed: a state, the maneuver ahead and the one after it (a step number, a ManeuverKind, a roundabout exit, an arrival side, a distance), and what is left.

The NavRoute Pathfinder holds for a Google trip has steps: [] and name: "". Nothing that could carry a Google road name exists on Pathfinder's side, so no road name can reach Pippa's voice, her report or the drive log, however a future screen is written. That is the strong form of the rule: not "remember not to read the words" but "there are no words to read".

GoogleManeuver maps every GMSNavigationManeuver to one of Pathfinder's kinds. depart and nameChange are marked passing (nothing to do, nothing to say), and the engine folds their length into the way to the next real maneuver. A roundaboutExit is an exit, because you are already inside the roundabout. Then ManeuverWords turns the kind into Pathfinder's own closed vocabulary: 좌회전, 회전교차로에서 두 번째 출구로 나가기, 오른쪽에 목적지가 있음. FeedTrip decides when to speak with the same stage distances as Pathfinder's own engine, so a drive sounds the same whoever guides. On Google's map, Google's header shows the maneuver, the road and the lanes; that is Google showing Google's content on Google's map. Footer and recenter button are off, speed limit and speedometer on. The SDK's own voice starts .silent; it is a setting for the day Dad wants Google's full prompts.

One small Swift trap lives in that mapping. Google's enum and Pathfinder's share case names, so an unlabeled GoogleManeuver(.turnLeft) inside the mapping resolved to Google's .turnLeft and called the mapping again, forever. The compiler only warned. The initializer the mapping uses is labeled, GoogleManeuver(kind:), for exactly that reason.

The terms dialog needs the screen to itself

The navigation session is made only after Dad acknowledges Google's terms in Google's own dialog (publisher shown as "CWK"). Coop learning #754 recorded the catch: asked for while a SwiftUI sheet covers the map, or while one is still closing, the dialog may never appear and its callback never run, and a continuation waiting on that callback would hold every later route request. So the engine asks when a Google map first comes on screen, not in the middle of a route request. It waits until the key window's root presents nothing, polling every 250 ms for at most 1,200 polls (five minutes), bounds the answer itself to 120 s, and if Dad accepts after that, makes the session then and takes the warning back.

The sentinel that traps Int()

The same learning found the nastier bug. With no route, the SDK reports distanceToNextDestination as CLLocationDistanceMax and timeToNextDestination as CLTimeIntervalMax. Both are the largest finite Double. isFinite passes them, and Int(_:) traps on them: the app would crash formatting "how far is left". The fix bounds every number at every boundary. FeedTrip.plausible takes a distance only below 100,000 km, a time only below 10 million seconds, a speed only below 200 m/s; anything else is dropped and the last good value stands. SpokenDistance says "soon" (잠시 후) instead of converting, and the screen's Format prints a dash. The third code block reproduces the trap in a dozen lines.

When the feed never comes

Pippa can only speak what the feed tells her. If no reading arrives within 25 seconds of guidance starting, the engine tells the trip, Google's own voice takes the turns, and the screen and the drive log both record why. A trip is never guided by two voices, and never by none without saying so.

The doc that lags

Read the architecture doc's status section and you will find it saying in-app Google guidance "remains unenabled", and the provider doc saying custom Google guidance "is not enabled". Those lines describe the activation record from before the ruling. The code, invariant 19 and commit 6782c3e say otherwise, and a later docs commit records the console change that enabled the Navigation SDK on the phone's key. The engine was built against the SDK's headers and proven by the suites with a scripted feed; Google's map and its guidance have since been used on Dad's phone abroad. The Mac suites still cannot run it, because the key lives only on his phone.

Make the forbidden thing unrepresentable. A rule that says "never speak Google's words" has to be remembered by every future change. A route whose steps array is empty and whose name is blank cannot be spoken by anyone, however careless.

Code

PathfinderMobile, Providers/Google/GoogleEngine.swift: the turn feed, reduced to shape and distance (verbatim excerpt)·swift
  /// The maneuver ahead and the one after it. A step that is no maneuver (the
  /// departure, a road that only changes its name) is passed over, and its
  /// length joins the way to the next real one.
  static func maneuvers(in navInfo: GMSNavigationNavInfo) -> (
    next: FeedReading.Step?, following: FeedReading.Step?
  ) {
    guard let current = navInfo.currentStep else { return (nil, nil) }
    var next: FeedReading.Step?
    var metres = navInfo.distanceToCurrentStepMeters
    for (index, step) in ([current] + navInfo.remainingSteps.prefix(8)).enumerated() {
      if index > 0 { metres += step.distanceFromPrevStepMeters }
      if GoogleManeuver(step.maneuver).isPassing { continue }
      if next == nil {
        next = FeedReading.Step(step, distance: metres)
        metres = 0
      } else {
        return (next, FeedReading.Step(step, distance: metres))
      }
    }
    return (next, nil)
  }
PathfinderKit, Guidance/FeedTrip.swift: only plausible numbers are taken from a feed (verbatim excerpt)·swift
  /// A distance or a time the trip can show. A provider's engine reports "no
  /// route" as the largest number it has, and that is not what is left.
  static func plausible(_ value: Double, below limit: Double) -> Bool {
    value.isFinite && value >= 0 && value < limit
  }

  public mutating func read(_ reading: FeedReading) -> [GuidanceEvent] {
    guard status.phase != .arrived else { return [] }
    var reading = reading
    var events: [GuidanceEvent] = []
    // What is left is taken only while the provider is on a route, and only
    // when it is a number of this world; otherwise the last one stands.
    if reading.state == .enroute {
      if Self.plausible(reading.remainingDistance, below: 100_000_000) {
        status.remainingDistance = reading.remainingDistance
      }
      if Self.plausible(reading.remainingTime, below: 10_000_000) { status.remainingTime = reading.remainingTime }
    }
    if let speed = reading.speed, !Self.plausible(speed, below: 200) { reading.speed = nil }
    if let next = reading.next, !Self.plausible(next.distance, below: 100_000_000) { reading.next = nil }
    if let following = reading.following, !Self.plausible(following.distance, below: 100_000_000) {
      reading.following = nil
    }
A minimal sketch: the sentinel isFinite lets through, and the guard that keeps it from Int() (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of a "no route" sentinel and the guard that keeps it out
// of an Int conversion. The stand-in is the largest finite Double: Core
// Location's CLLocationDistanceMax and CLTimeIntervalMax both equal it.
let noRoute = Double.greatestFiniteMagnitude

print("isFinite passes it:", noRoute.isFinite)
print("Int(exactly:) refuses it:", Int(exactly: noRoute) as Any)
// Int(noRoute) here would trap at run time: the value is greater than Int.max.

/// A distance or a time this world can have.
func plausible(_ value: Double, below limit: Double) -> Bool {
  value.isFinite && value >= 0 && value < limit
}

/// What the screen prints: a dash for anything that cannot be a distance.
func distance(_ metres: Double) -> String {
  guard plausible(metres, below: 100_000_000) else { return "–" }
  if metres < 1000 { return "\(Int((metres / 10).rounded()) * 10) m" }
  return String(format: metres < 10_000 ? "%.1f km" : "%.0f km", metres / 1000)
}

for metres in [287.0, 4_380.0, noRoute, .nan, -1] {
  print(metres, "->", distance(metres))
}
// isFinite passes it: true
// Int(exactly:) refuses it: nil
// 287.0 -> 290 m
// 4380.0 -> 4.4 km
// 1.7976931348623157e+308 -> –
// nan -> –
// -1.0 -> –

External links

Exercise

Search a Swift (or any typed-language) project of yours for every place a Double from an outside source is converted to an Int or formatted for display: Int(x), Int(x.rounded()), String(format:). For each one, write down where the number came from and what its largest plausible value is. Add a single plausible(_:below:) helper and route every conversion through it, then write a test that feeds Double.greatestFiniteMagnitude, .nan, .infinity and -1 to each formatter.
Hint
A test that calls Int(Double.greatestFiniteMagnitude) crashes the test process instead of failing, so test the formatter's output ("–") rather than the conversion itself. Int(exactly:) is a safe way to probe a value in a test when you need to.

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.