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

The Phone Sleeps: Ask, Don't Wait

~18 min · on-the-wrist, watchconnectivity, background-execution, pull, application-context

Level 0Bundle Opener
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"At worst, the watch should at least tell me to wake the phone, so I know what's going on."

A Ring Buys Seconds

The doorbell from the WatchConnectivity lesson is real: sendMessage launches the iOS counterpart when it is not running. This month the family's conversation app found its two limits, both in the SDK header once you know to look. First, a message goes only while the sending app is running and the counterpart is reachable, and the family's watch rang only while the phone counted as reachable. A wearer who speaks and then lowers the arm during the three-second end-of-speech wait sends the recording after that moment has passed, so it reached the phone's WatchConnectivity daemon with no ring at all and sat there until the phone was unlocked two minutes later. Second, a phone app launched by a ring gets a short spell of background time, about thirty seconds in the family's measurements, which its receiver holds with a background task. A Master Chief turn that took 26 seconds to answer, plus the stream that carried it, outlived that spell: the app was suspended mid-stream, the push that should have woken it again woke nothing, and the wrist said "Pippa is thinking…" for three minutes until the phone app was opened by hand.

So a ring wakes the phone; it does not keep it awake. Any design that rings once and then waits is betting that the whole exchange fits inside a few seconds of borrowed time.

The Side That Is Owed Asks

The cure is to stop waiting. While the watch is owed something (a reply, or the voice of a reply), it pulls: every 15 seconds it sends a tiny file through transferFile with a ring beside it, and the ring makes the phone's receiver hold background time until the file lands. The phone answers every pull with where the request stands: working, queued, unreachable with its reason, answered, or reading the reply aloud. A phone that is awake answers each pull, so even a long answer never looks like silence. Raising the wrist pulls at once instead of at the next 15-second turn, and a request older than fifteen minutes is no longer pulled for, because a reply that never came is a story for the phone's own transcript.

The owner's request settles what happens when the answers stop. After 60 seconds with no word from the phone, which is four pulls unanswered, the watch taps the wrist and says so: "No word from the iPhone. Open Pippa on the iPhone to wake it." The same words also sit in a local notification, scheduled one pull later, 75 seconds after the phone's last word. The watch reschedules it on every pull, but only a new word from the phone moves it later. A running watch speaks at 60 seconds; a watch that was itself suspended and could not pull still says it 15 seconds after that. "Thinking" is a claim about the phone; when the watch cannot back it, it says what it actually knows.

Say It Again Until They Stop Asking

The same trap waits on the phone's side. When the phone reads a reply into the AirPods, it tells the watch twice: when the reading starts and when it ends. Sent once each, either word can be missed by a watch that was suspended at that moment, and a watch that missed the end word stays on "Thinking" until a long fallback timer runs out. Now the phone keeps its last word about each request, the newest twenty, and repeats it in every answer to a pull until the watch stops asking. Two details make the repeats safe. Each repeat is stamped with the moment the reading started or ended, never the moment of the repeat, so a late "reading" is counted from when it was true. And the watch ignores a reading it already knows about, so a repeat can never push the end of the turn later. A word that matters is state, not an event: the receiver can ask for it as many times as it needs.

Read When the Link Comes Up

Pathfinder's watch shows the phone's guidance from receivedApplicationContext, read at launch and when the app comes to the front. On a walk in Seville the watch stopped picking up a guidance that was running, though nothing in the wrist lane had changed for a week. Both reads can run before the session has activated, and a session that has not activated has nothing to read; that empty read stayed empty until the phone published again, which a pocketed, suspended phone never does. The fix has the same two halves as above. The watch reads again when activation completes. And a watch at the front that holds no state, or state older than ten seconds, pulls the phone while it is reachable, at most once every ten seconds. The full story is in the Pathfinder quest.

Acts Ride the Outbox; Facts Ride the Context

Pathfinder's watch also said "my navigation session is live" every 30 seconds through the capture lane, the durable outbox from the outbox lesson. With the phone out of reach, the queue grew by two entries a minute, and every activation offered the whole pile again. The capture lane is for acts, things that happened once and must arrive once. A latest-only fact, such as presence, a setting or a position, belongs on updateApplicationContext, which keeps only the newest value. Calling it takes no delegate of its own, so the session's one retained delegate stays the only one; the session still needs that delegate installed and activated before it sends anything. The receiver reads it at the moment it decides and judges freshness by the fact's own timestamp.

Staying Awake Takes a Reason iOS Accepts

Pulls get the phone's attention for seconds at a time. Some exchanges need the phone awake for the whole conversation, for example to start a reading in the background. iOS keeps an app running in the background only while it does work the system recognizes. Pathfinder is never suspended while it guides, because its process holds a location subscription. For the conversation app the owner made that a product decision, with consent: while a wrist conversation runs, the phone runs standard location updates and throws the positions away. It starts on any wrist pull and ends 30 seconds after the conversation ends, or after five quiet minutes, and the app asked for Always location once to do it. That is a privacy and battery trade the owner chose and recorded. It is not a trick to copy without the same decision.

Code

The wrist's wait as a pure policy: pull while owed, say so when the phone goes quiet·swift
import Foundation

/// Where a request stands, in the phone's words. The wrist shows nothing it did not hear.
enum PhoneWord: String, Codable, Sendable {
    case working, queued, unreachable, answered, reading, readAloud, readingFailed
}

/// The side that is owed the answer keeps asking. Pure, so the phone's test suite can hold it.
enum WaitPolicy {
    static let interval: TimeInterval = 15        // ring the phone again this often
    static let silence: TimeInterval = 60         // four pulls unanswered: say the phone is quiet
    static let window: TimeInterval = 15 * 60     // past this, the phone's transcript tells the story

    struct Step: Equatable {
        var pull: Bool
        var phoneIsQuiet: Bool
    }

    /// nil once the request is too old to keep asking about.
    static func step(sentAt: Date, lastHeard: Date?, lastPull: Date?, now: Date) -> Step? {
        guard now.timeIntervalSince(sentAt) < window else { return nil }
        let quiet = now.timeIntervalSince(lastHeard ?? sentAt) >= silence
        let due = lastPull.map { now.timeIntervalSince($0) >= interval } ?? true
        return Step(pull: due, phoneIsQuiet: quiet)
    }

    /// The local notification that says it for a watch suspended too long to pull. One pull
    /// later than the in-app line (75 s), so a running watch speaks first. Rescheduled on every
    /// pull, always from the phone's last word: only a new word moves it later.
    static func noticeDate(lastHeard: Date?, sentAt: Date) -> Date {
        (lastHeard ?? sentAt).addingTimeInterval(silence + interval)
    }
}
The phone's side: keep the last word, repeat it on every pull, stamped with when it became true·swift
import Foundation

/// The phone's last word about each wrist request: said when it happens, and said again in every
/// answer to a pull until the wrist stops asking. A word sent once can land on a sleeping watch.
struct LastWords {
    static let keep = 20

    struct Word: Equatable, Sendable {
        var word: PhoneWord
        var at: Date            // when it became true, never when it was repeated
    }

    private(set) var words: [String: Word] = [:]
    private var order: [String] = []

    mutating func say(_ word: PhoneWord, about id: String, at: Date = .now) {
        words[id] = Word(word: word, at: at)
        order.removeAll { $0 == id }
        order.append(id)
        while order.count > Self.keep { words[order.removeFirst()] = nil }
    }

    /// What a pull about `id` is answered with.
    func answer(for id: String) -> Word? { words[id] }
}

// On the wrist: a "reading" the turn already knows of is ignored, so a repeat
// never restarts the reading and never pushes the turn's end later.
//
// And on activation, read the latest fact again: a read at launch may have run
// before the session could answer.
//   func session(_ s: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
//       guard state == .activated else { return }
//       onLinkUp(s.receivedApplicationContext)
//   }

External links

Exercise

Give Spark's watch an ask-a-phrase request that waits for the phone's spoken answer. Write tests for WaitPolicy: a fresh request pulls at once, a second step 10 seconds later does not, one 15 seconds later does, a minute of silence sets phoneIsQuiet, and a request past the window returns nil. Write tests for LastWords: the twenty-first request drops the oldest, and a repeat carries the original moment. Then, on a real phone and watch, lock the phone in a pocket, speak, and lower your arm during the end-of-speech wait. Record how long the answer takes with pulls off and with pulls on.
Hint
Inject now everywhere so the tests never sleep. In the device test, the phone's own log tells you when the file reached its WatchConnectivity daemon and when the app first heard it; the gap between the two is the time nothing rang.

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.