본문 바로가기
C.W.K.
Stream
Lesson 04 of 04 · published

잠긴 폰으로 받는 회전 안내

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

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

폰은 주머니 속에 있어

운전할 땐 거치대에 폰이 있고 스피커가 있어. 걸을 땐 둘 다 없어. 폰은 잠긴 채 주머니에 들어 있고, 길은 시끄럽고, 다음 회전을 보여주던 화면은 꺼져 있어. 피파 목소리는 그래도 나와. 앱이 오디오 백그라운드 모드를 쥐고 있고, 화면이 꺼져도 안내는 계속 도니까. 하지만 북적이는 보도에서 옷감에 막힌 목소리는 놓치기 쉬워. 워치 앱을 만들 때 아빠가 답을 정했어. "도보는 특히 워치도 지원해야 하고."

회전 안내가 손목에 닿는 길은 둘이야. 하나는 워치 앱이 직접 톡 치는 거야. 그런데 그건 워치 자기 내비게이션 세션이 돌 때만 되고, watchOS는 그 세션을 워치 앱이 앞에 떠 있을 때만 시작하게 해줘. 걷는 내내 그걸 열어두는 사람은 별로 없어. 다른 길은 워치 코드가 아예 필요 없어. Apple 규칙이 이래. 워치가 잠겨 있지 않으면, iPhone이 잠겨 있거나 잠들어 있을 때 알림은 Apple Watch로 가. 그러니까 폰이 올린 알림은 워치 앱이 닫혀 있어도 손목까지 가는 유일한 길이야.

그래서 소리를 더 키우는 대신 회전을 알림으로 내보내. 주머니에서 나오는 소리는 거리 소음이랑 싸워야 해. 잠긴 폰의 알림은 손목으로 넘어가고, 손목이 톡 쳐. 목소리는 계속 말해. 알림은 귀 대신 몸에 닿는 두 번째 통로야.

회전이 알림이 되는 순간

결정은 PathfinderKit의 TurnAlertPolicy.shouldAlert가 해. 입력 여섯 개짜리 순수 함수고, 테스트 표가 붙어 있어. 아래가 전부 맞을 때만 알림이 나가.

  • 회전 알림이 켜져 있어. "잠금 화면 회전 알림" 설정은 기본이 켜짐이야.
  • 도보야. 운전하는 사람은 글을 읽으면 안 되니까.
  • 폰 앱이 화면에 안 떠 있어. 떠 있으면 아빠가 이미 회전을 보고 있는 거야.
  • 워치 자기 세션이 살아 있지 않아. 살아 있으면 열린 워치 앱이 알아서 쳐. 같은 순간에 두 번 치면 잡음이야.
  • 단계가 prepare나 imminent거나, 도착이야. early 단계는 도보에선 200 m 밖이라 지금 할 게 없어.

"살아 있다"는 짐작이 아니라 재는 거야. 워치는 세션이 도는 동안 30초마다 자기 application context로 watching(true)라는 존재 신호를 올려. 폰은 마지막 신호를 75초 동안 믿어. 조용해진 워치나 앱을 쓸어서 닫은 워치는 더 이상 살아 있는 걸로 안 치고, 알림이 다시 나가. 존재 신호를 capture lane 말고 application context에 태운 데도 이유가 있어. 보낸 걸 끝까지 쥐고 있는 큐에 넣은 심장 박동은 폰이 멀리 있는 동안 쌓였고, 다시 연결될 때마다 쌓인 걸 통째로 다시 내밀었거든.

규칙 하나의 두 반쪽

워치 쪽엔 같은 규칙의 거울상이 WatchModel.tapIfDue에 있어. 폰이 보낸 글랜스에 실린 신호로 워치가 톡 치는 건 자기 세션이 돌 때, 폰 앱이 앞에 떠 있을 때, 아니면 운전일 때뿐이야. 폰 앱이 뒤에 있고 워치 세션도 없는 도보에선 워치가 가만히 있어. 알림이 대신 칠 테니까. 둘을 같이 읽으면 순간마다 정확히 한 번 쳐. 회전마다 한 번이 아니야. 도보 회전 하나엔 치는 순간이 둘 있어. prepare, 그다음 imminent("Now"). 알림 식별자에 단계가 들어가는 것도 그래서야. 회전 하나가 손목에 두 번 닿는 건 설계대로야. 이 둘이 막는 건 같은 순간이 두 번 치는 거야. 세션이 있으면 워치 세션이, 없으면 넘어온 알림이 쳐. 둘 다 치는 일은 없어.

Google 도보에서도 같은 순간이 와. FeedGuidedTrip이 Google 피드를 같은 moment 고리로 알리거든. Google SDK도 자기 백그라운드 알림을 올릴 수 있는데, Pathfinder는 그걸 꺼(sendsBackgroundNotifications = false). 도보 회전은 자기 말로 직접 올려. 시뮬레이션 중인 길은 아무것도 안 올려.

알림을 올리는 쪽

올리는 건 앱의 TurnAlerts야. 일부러 작게 만들었어. 제목은 사람이 읽을 수 있게 다듬은 동작이고(도착이면 "Arriving"), 본문은 거리야. imminent 단계면 "Now". 알림은 전부 한 스레드를 써. 식별자는 구간 번호랑 단계로 만들고, 새 알림을 올리면 이전 것들은 잠금 화면에서 지워. 그래서 잠금 화면엔 앞에 있는 회전 하나만 남아. 도보가 끝나거나 도착하면 올린 건 다 치워.

권한은 딱 한 번, 첫 도보가 시작될 때 물어. 왜 묻는지가 뻔히 보이는 순간이니까. XCTest에서 돌 땐 알림 센터가 nil이야. 그래서 호스트 테스트가 권한 창을 띄울 일이 없어. 갓 만든 시뮬레이터에선 그 창에 영영 아무도 대답 안 하거든.

증명된 것과 아닌 것

정책은 증명할 수 있는 데까진 증명돼 있어. 킷의 turnAlertsReachTheWristOnlyWhenNothingElseWill이 위 표의 줄을 하나하나 확인하고, 존재 신호 테스트들이 75초 신뢰를 확인해. 앱의 호스트 테스트는 설정 기본값이 켜짐인지 확인해. 실제로 알림을 올리는 부분은 거기서 안 돌아. 테스트는 알림 센터 없이 돌게 설계돼 있거든.

마지막 고리는 Pathfinder 코드가 아니야. 잠긴 폰의 알림을 손목으로 넘기는 건 iOS가 하는 일이고, Pathfinder가 못 보는 조건이 붙어 있어. 워치가 잠금 해제돼 있어야 하고, 알림은 평범한 .active 수준으로 올라가는데 집중 모드가 그걸 붙잡아둘 수 있어. 프로젝트 노트에도 실제 WatchConnectivity 타이밍은 아직 아빠 기기로 증명해야 할 몫이라고 적혀 있어. 도보 중에 넘어온 회전 알림이 아빠 손목을 쳤다고 확인한 기록은 내가 못 찾았어. 잠금 화면, Dynamic Island, 워치 스마트 스택에 회전을 올려줄 Live Activity는 다음 후보로 적혀 있을 뿐 아직 안 만들었어. 기록이 말하는 그대로 말하자. 설계했고, Mac에서 테스트했고, 길 위의 검증을 기다리는 중이야.

통로 말고 몇 번 치는지를 세. 사람한테 닿으려고 통로를 하나 더할 때마다 두 번 닿을 기회도 하나 생겨. 통로마다 규칙을 쓰되, 다 같이 읽었을 때 모든 상황을 정확히 한 번씩 덮게 써. 그리고 통로 하나씩 말고 표 전체를 테스트해.

Code

PathfinderKit, Wrist/WristCommand.swift: 회전이 알림이 되는 조건 (원문 발췌)·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: 잠금 화면엔 앞의 회전 하나만 남아 (원문 발췌)·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]
  }
직접 쓴 최소 스케치: 정책과 워치의 75초 신뢰를 킷 테스트 표로 다시 돌려보기 (swiftc로 컴파일해서 돌려봄)·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

통로 둘 이상으로 사람한테 닿는 네 기능 하나를 골라봐. 푸시랑 이메일이든, 소리랑 배지든, 폰이랑 워치든. 통로마다 결정을 순수 함수로 적어. 입력은 그때의 상황이야. 그리고 전부 합친 진리표를 만들어. 사람한테 두 번 닿는 줄이랑, 한 번도 안 닿는 줄을 다 찾아봐.
Hint
틀어지는 줄은 대개 전환하는 순간이야. 이벤트 도중에 앱이 앞으로 나오거나, 두 번째 기기가 붙거나 떨어지거나, 설정이 바뀌거나. "다른 기기 상태를 마지막으로 확인한 게 얼마나 최근인가" 열을 하나 더 넣고, Pathfinder의 75초 같은 시간 제한이 그 틈을 메우는지 봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.