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

Turns on a Locked Phone

~15 min · swift, user-notifications, watchos, walking, policy

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

The phone is in a pocket

A drive has a mounted phone and a speaker. A walk has neither. The phone is locked in a pocket, the street is loud, and the screen that showed the next turn is dark. Pippa's voice still speaks (the app holds the audio background mode, and guidance keeps running with the screen off), but a voice muffled by fabric on a busy pavement is easy to miss. Dad named the answer when the watch app was built: "도보는 특히 워치도 지원해야 하고", walking above all should support the watch.

There are two ways a turn can reach a wrist. The watch app can tap it, but only while the watch's own navigation session runs, and watchOS lets that session start only with the watch app in front. Most of a walk, nobody opens it. The other way needs no watch code at all. Apple's own rule: if your iPhone is locked or asleep, you get notifications on your Apple Watch, unless your Apple Watch is locked. So a notification posted by the phone is the one path to the wrist that works when the watch app is closed.

That is why the turns go out as notifications rather than as more audio. Audio from a pocket competes with the street; a notification on a locked phone is handed to the wrist, which taps. The voice keeps speaking; the notification is a second channel that reaches the body instead of the ear.

When a turn becomes a notification

The decision is TurnAlertPolicy.shouldAlert in PathfinderKit, a pure function with six inputs and a test table. A turn goes out only when all of these hold:

  • Turn alerts are on. The setting "Turn alerts on the lock screen" (잠금 화면 회전 알림) defaults to on.
  • It is a walk. A driver should not be reading.
  • The phone app is not on screen. If it is, Dad is already looking at the turn.
  • The watch's own session is not live. If it is, the open watch app taps by itself, and two taps for the same moment is noise.
  • The stage is prepare or imminent, or it is the arrival. The early stage, 200 m out on a walk, is too far ahead to act on.

"Live" is measured, not assumed. The watch publishes a presence, watching(true), as its own application context every 30 seconds while its session runs, and the phone believes the last one for 75 seconds. A watch that went quiet, or whose app was swiped away, stops counting as live, and the notifications resume. The presence rides the application context rather than the capture lane on purpose: a heartbeat in a durable queue piled up while the phone was away, and every reconnect re-offered the whole pile.

Two halves of one rule

The watch has the mirror image of the same rule in WatchModel.tapIfDue. It taps a cue from the phone's glance only if its own session runs, or the phone app is in front, or the trip is a drive. On a walk with the phone app in the background and no watch session, it stays quiet, because the notification is the tap. Read together, the phone's policy and the watch's guard make exactly one tap per moment, not per turn. A turn on a walk has two moments that tap, prepare and then imminent ("Now"), and the notification identifier carries the stage for exactly that reason, so a turn reaches the wrist twice by design. What the pair rules out is the same moment twice: it taps through the watch's session when it has one, through the mirrored notification when it does not, and never through both.

On a Google walk the same moments arrive, because FeedGuidedTrip reports Google's feed through the same moment hook. Google's SDK could post its own background notifications; Pathfinder turns that off (sendsBackgroundNotifications = false) and posts the walk's turns itself, in its own words. A simulated trip posts nothing.

The poster

TurnAlerts in the app does the posting, and it is small on purpose. The title is the maneuver in readable words ("Arriving" for the arrival), the body is the distance, or "Now" for the imminent stage. Every notification shares one thread. Each has an identifier made of the step and the stage, and posting one removes the earlier ones from the lock screen, so only the turn ahead stays there. When the walk ends or arrives, everything it posted is cleared.

Permission is asked once, when the first walk starts, the moment the reason for asking is obvious. Under XCTest the notification center is nil, so a hosted test suite never presents a prompt that a fresh simulator would never answer.

What is proven, and what is not

The policy is proven where it can be. turnAlertsReachTheWristOnlyWhenNothingElseWill in the kit's suite checks every row of the table above, and the presence tests check the 75-second trust. The app's hosted tests check that the setting defaults to on. The posting itself is not exercised there: the suite runs with no notification center, by design.

The last link is not Pathfinder's code. Mirroring a locked phone's notification to the wrist is iOS's behaviour, and it has conditions Pathfinder cannot see: the watch must be unlocked, and the notifications are posted at the ordinary .active interruption level, which a Focus can hold back. The project's own notes say real WatchConnectivity timing is still Dad's devices' to prove, and I found no entry in the record that confirms a mirrored turn tapping Dad's wrist on a walk. A Live Activity, which would put the turn on the lock screen, the Dynamic Island and the watch's Smart Stack, is listed as the next candidate, not built. Say it the way the record does: designed, tested on a Mac, waiting for the street.

Count the taps, not the channels. Every channel you add to reach a person is a chance to reach them twice. Write the rule for each channel so that, read together, they cover every situation exactly once, and test the table, not the channels one by one.

Code

PathfinderKit, Wrist/WristCommand.swift: when a turn becomes a notification (verbatim excerpt)·swift
public enum TurnAlertPolicy {
  /// Walks only: a driver should not be reading. Only the prepare and imminent
  /// stages (the early one is too far ahead to act on). Never while the phone
  /// app is on screen (Dad is looking at it), and never while the watch's own
  /// navigation session is live (the open watch app taps by itself; two taps
  /// for one turn is noise).
  public static func shouldAlert(
    stage: AnnouncementStage, isArrival: Bool, mode: TravelMode, appActive: Bool, watchLive: Bool, enabled: Bool
  ) -> Bool {
    guard enabled, mode == .walking, !appActive, !watchLive else { return false }
    return isArrival || stage >= .prepare
  }
PathfinderMobile, Wrist/TurnAlerts.swift: only the turn ahead stays on the lock screen (verbatim excerpt)·swift
  /// The turn ahead: its words as the title, how far as the body.
  func post(_ announcement: Announcement, distance: String) {
    guard let center else { return }
    let content = UNMutableNotificationContent()
    content.title =
      announcement.isArrival ? "Arriving" : InstructionChunker.readable(announcement.instruction)
    content.body = distance
    content.sound = .default
    content.threadIdentifier = Self.thread
    content.interruptionLevel = .active
    let id = "pathfinder.turn.\(announcement.stepIndex).\(announcement.stage.rawValue)"
    center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
    // Only the turn ahead stays on the lock screen.
    let earlier = posted.filter { $0 != id }
    if !earlier.isEmpty { center.removeDeliveredNotifications(withIdentifiers: earlier) }
    posted = [id]
  }
A minimal sketch: the policy and the watch's 75-second trust, replayed against the kit's test table (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch: TurnAlertPolicy's two questions, copied from PathfinderKit,
// replayed against the kit's own test table.
enum Stage: Int, Comparable { case early, prepare, imminent
  static func < (a: Stage, b: Stage) -> Bool { a.rawValue < b.rawValue } }
enum Mode { case driving, walking }

let watchingTrust: TimeInterval = 75  // the watch says "live" every 30 s

func shouldAlert(stage: Stage, isArrival: Bool, mode: Mode, appActive: Bool, watchLive: Bool, enabled: Bool) -> Bool {
  guard enabled, mode == .walking, !appActive, !watchLive else { return false }
  return isArrival || stage >= .prepare
}

func watchLive(lastWatching: Date?, live: Bool, now: Date) -> Bool {
  guard live, let lastWatching else { return false }
  return now.timeIntervalSince(lastWatching) <= watchingTrust
}

func alert(_ stage: Stage, mode: Mode = .walking, active: Bool = false, watch: Bool = false, enabled: Bool = true) -> Bool {
  shouldAlert(stage: stage, isArrival: false, mode: mode, appActive: active, watchLive: watch, enabled: enabled)
}

let now = Date()
let rows: [(String, Bool)] = [
  ("prepare, app not on screen", alert(.prepare)),
  ("imminent", alert(.imminent)),
  ("early (too far ahead to act on)", alert(.early)),
  ("prepare, but driving", alert(.prepare, mode: .driving)),
  ("prepare, app on screen", alert(.prepare, active: true)),
  ("prepare, watch's own session live", alert(.prepare, watch: true)),
  ("prepare, switched off in Settings", alert(.prepare, enabled: false)),
  ("arrival on a walk", shouldAlert(stage: .early, isArrival: true, mode: .walking, appActive: false, watchLive: false, enabled: true)),
  ("watch said live 40 s ago", watchLive(lastWatching: now.addingTimeInterval(-40), live: true, now: now)),
  ("watch said live 90 s ago", watchLive(lastWatching: now.addingTimeInterval(-90), live: true, now: now)),
]
for (name, value) in rows { print(value ? "yes" : "no ", name) }

// Output:
// yes prepare, app not on screen
// yes imminent
// no  early (too far ahead to act on)
// no  prepare, but driving
// no  prepare, app on screen
// no  prepare, watch's own session live
// no  prepare, switched off in Settings
// yes arrival on a walk
// yes watch said live 40 s ago
// no  watch said live 90 s ago

External links

Exercise

Pick a feature of your own that reaches a person through more than one channel: a push and an email, a sound and a badge, a phone and a watch. Write the decision for each channel as a pure function of the situation, then build the truth table of all of them together. Find every row where the person is reached twice, and every row where they are reached not at all.
Hint
The rows that go wrong are usually the transitions: the app comes to the front mid-event, a second device connects or drops, a setting is toggled. Add a column for "how fresh is our belief about the other device" and see whether a timeout like Pathfinder's 75 seconds closes the gap.

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.