Skip to content

RFC: honor the phone's Do Not Disturb on the watch (ANCS silent flag)#1772

Draft
vvzvlad wants to merge 1 commit into
coredevices:mainfrom
vvzvlad:rfc/phone-dnd-silent
Draft

RFC: honor the phone's Do Not Disturb on the watch (ANCS silent flag)#1772
vvzvlad wants to merge 1 commit into
coredevices:mainfrom
vvzvlad:rfc/phone-dnd-silent

Conversation

@vvzvlad

@vvzvlad vvzvlad commented Jul 22, 2026

Copy link
Copy Markdown

Paired PR: the companion-app half lives in coredevices/mobileapp#304 — it makes the existing "Respect Phone Do Not Disturb" toggle work on both platforms (un-gating it and syncing the notifRespectPhoneSilence watch pref to the firmware) and declares the TimelineItem.Flag.SILENT wire flag (bit 6). That PR deliberately leaves Android behaviour unchanged (it implements only the iOS-enabling half); the Android wiring is called out there as a maintainer decision. This firmware PR is self-contained and inert without the synced pref.

The problem

When an iPhone is in Do Not Disturb / Focus, the watch still buzzes and pops up every notification. To get silence you mute the phone, then separately mute the watch, then remember to undo both. I went Pebble → Garmin → back to Pebble, and inheriting the phone's silence is the one thing I miss — on Garmin the watch just mirrors the phone.

What the firmware does today

prv_handle_ns_notification() reads the ANCS EventFlags and acts on exactly two bits — EventFlagMultiMedia and EventFlagPreExisting. EventFlagSilent is declared in ancs_types.h and tested nowhere, so the phone's silent state has no effect on the watch. Inherited from google/pebble, which didn't use the bit either.

What this PR does

Reads EventFlagSilent into a new per-item header flag (header.silent), and — when the opt-in notifRespectPhoneSilence preference is set — keeps silent notifications out of the popup/vibration/backlight path while still storing and listing them. Mirrors the existing Quiet Time hide path.

Deliberate design choices:

  • No watch UI. The preference is exposed over blob-db settings sync (s_syncable_notif_prefs), so the companion app owns the toggle. Defaults off — the firmware is inert until an app opts in. I deliberately did not add a watch-side settings row; see Why no watch menu row below.
  • Platform-neutral gate. Suppression is keyed on header.silent alone, not on the item being an ANCS notification. So a companion app could drive the same behavior on Android by setting the silent flag on the wire flags byte (TimelineItemFlagSilent, bit 6), with no firmware change — see question 1.
  • Storage-compatible. silent uses bit 6 of the header flags byte (always zero in stored items); bit 5 stays reserved for the existing TimelineItemFlagPersistent.

The flag

Apple's ANCS spec names bit 0 EventFlagSilent (Table 3-3 of the appendix). The name is the spec's own statement of intent — the notification is silent — so honoring it by staying silent on the watch is the natural reading.

Apple doesn't document when iOS sets it, so I characterized it empirically on-device. The bit maps to exactly one thing: the notification was intercepted by an active Do Not Disturb / Focus, honoring that Focus's per-app/per-person allow-list (an allow-listed sender does not get the bit, so it still alerts). It is not set by the hardware Ring/Silent switch, ringer volume, per-app "Mute for 1 hour", or Screen Time. Effectively the iOS counterpart of Android's Ranking.matchesInterruptionFilter().

Why no watch menu row

The notification path is asymmetric:

  • iOS: notifications reach the watch directly over ANCS; the companion app is never in the path (no iOS API to read other apps' notifications). Only the firmware can see the silent bit and act on it. Enforcement must be firmware-side.
  • Android: the app is in the path (NotificationListenerService), sees Ranking.matchesInterruptionFilter(), and already ships a "Respect Phone Do Not Disturb" toggle that drops locally. Enforcement is app-side.

A watch-side toggle would work on iOS but do nothing on Android (where the app owns it), or be a second toggle to reconcile. Instead: firmware exposes a synced preference and no UI; the app owns the visible toggle and enforces per platform.

For the record: my own daily-driver watch currently runs exactly that watch-side toggle as a personal stopgap, and it works fine for me on iOS. I'm deliberately not proposing it as this PR — a settings row that silently does nothing on Android is a bad product decision. I'd rather land the mechanism and let the companion app own the switch than ship that crutch.

Questions

1. How should Android be wired? Your call; it lives entirely in the companion app — the firmware commit is identical either way, and the paired app PR ships neither variant on Android (it keeps the status quo):

  • Leave as-is (what the app PR keeps): the Android app keeps dropping DND-suppressed notifications locally; this firmware change benefits iOS only (nothing sets header.silent on Android, so the gate is a no-op there).
  • Forward-but-quiet: the Android app would set TimelineItemFlagSilent and forward instead of dropping → DND-suppressed notifications appear silently in the list, matching iOS. (Changes existing Android behaviour: they currently vanish.) The app PR declares the wire flag so this needs no protocol change, but does not implement the emit path — it's left as a maintainer choice.

2. Is ANCS the right transport, given the exact trigger isn't spelled out by Apple? (I've now characterized it empirically — see The flag — but it's still Apple's undocumented behaviour, not a contract.) INFocusStatusCenter (iOS 15+) lets a companion app read Focus state with user authorisation and relay it — fully documented semantics, at the cost of an entitlement and app work.

Known behaviour

If the notification window is already open when a silent notification arrives, it is not inserted into the live list until the window reloads (still lands in storage / the notifications app). Identical to the existing Quiet Time hide early-return directly above the new gate — intentional, but flagged.

Tested

./pbl build (obelix) and the affected ./pbl test suites pass: test_ancs, test_ancs_notifications, test_notification_window (obelix + gabbro), test_timeline_item, test_notification_storage.

Unit tests added cover: the ANCS layer propagating the flag, the flag reaching the stored item, and the suppression path itself (with header.silent = true / header.ancs_notif = false, proving the gate is platform-neutral, plus the two negative cases).

On-device (obelix, via my personal watch-side-toggle build — same suppression path): with the preference on, notifications caught by an active Focus/DND land in the list silently, while everything else alerts normally — including senders allow-listed in the Focus. The hardware silent switch, ringer volume at 0, per-app "Mute for 1 hour", and Screen Time all leave the notification alerting (they do not set the flag); details under The flag.

Companion-app side — see the paired PR

coredevices/mobileapp#304 un-gates the existing "Respect Phone Do Not Disturb" toggle so it shows on both platforms and mirrors it to the synced notifRespectPhoneSilence pref — a hidden BoolWatchPref, so there's no duplicate row — which enables this feature on iOS; and it declares TimelineItem.Flag.SILENT as the wire counterpart of this bit. It intentionally does not change Android notification handling — the Android forward-but-quiet path (question 1) is left as a maintainer decision.

Read the ANCS EventFlagSilent bit into a per-item header flag and, when
the opt-in notifRespectPhoneSilence preference is set, keep silent
notifications out of the popup/vibration/backlight path while still
storing and listing them (mirrors the existing Quiet Time hide path).

The suppression is keyed on header.silent alone, not on the item being
an ANCS notification, so a companion app can drive the same behavior on
Android by setting the silent flag on the wire flags byte. The
preference is exposed over blob-db settings sync (no watch UI) so the
companion app owns the toggle; it defaults off, leaving the firmware
inert until an app opts in.

Storage-compatible: silent uses bit 6 of the header flags byte (always
zero in stored items); bit 5 stays reserved for TimelineItemFlagPersistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
@asyba

asyba commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Apple doesn't document what is EventFlagSilent if its the focus mode, or the silent switch hardware (note that I could find) and not sure that is correctly exposing and working, have you test this?

INFocusStatus only return a boolean saying if a focus mode is enable or not, but does't specific what focus mode (sleep,work,silence etc) not much you can do with that.

@vvzvlad

vvzvlad commented Jul 23, 2026

Copy link
Copy Markdown
Author

Yes, I came up with a workaround, and I’ve been using firmware for a while now that stops displaying notifications when this flag is set. As far as I can tell, the behavior is simple: if a notification triggers a sound or vibration on the phone, the flag isn’t sent; if the notification arrives silently on the phone, the flag is sent. This behavior is identical to that of my Garmin watch.

@asyba

asyba commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Oh, interesting.
So ANCS only sends the flag if the user is in a focus mode that silences notifications from app X, right?
Could be the default silent mode or some custom focus mode that the user selects to silence notifications from app X.

What about:

  • screen time apple settings or third-party apps that use the screen time API ?
  • if user has volume down to 0 ?

INFocusStatus is used into the pebble core app to do what? why do we need mobile side updates?

@vvzvlad

vvzvlad commented Jul 23, 2026

Copy link
Copy Markdown
Author

Tested on-device (my firmware suppresses on the flag → "watch stays silent = flag set; watch alerts = flag not set"):

Flag SET: classic Do Not Disturb, and any custom Focus where the sending app isn't allowed → watch stays silent.

Flag NOT set:

  • Focus exceptions — Focus active but the sender added to Allowed People/Apps → alerts on the watch normally. So it's granular: it reflects whether this specific notification was caught by the interruption filter, honoring the Focus's allow-list.
  • Hardware Ring/Silent switch and ringer volume at 0 — both only mute the phone's ringer; the watch still alerts.
  • Per-app "Mute for 1 hour" — suppressed on the phone, but still alerts on the watch.
  • Screen Time (app limit) — nothing on the phone, yet it still reaches the watch and alerts.

So EventFlagSilent maps to exactly one thing: the notification was intercepted by an active Do Not Disturb / Focus, with that Focus's allow-list respected. Not the hardware silent switch, ringer volume, per-app mute, or Screen Time. Effectively the iOS counterpart of Android's Ranking.matchesInterruptionFilter(). (So, to your first question: it's Focus, not the hardware switch.)

On INFocusStatus: it's not used in the app. I only brought it up as an example that there's more than one way to solve this — and you're right, if it only reports focus on/off it's useless for a per-notification decision, so it's not the approach here.

The mobile-app change is two things: (1) un-gate the existing "Respect Phone Do Not Disturb" toggle, which today is Android-only; (2) sync that toggle's state to the watch. The reason is that the notification paths differ per platform — on Android the app is in the path and can filter on the phone; on iOS notifications go straight to the watch over ANCS, so the filtering has to happen on the watch, and the watch has to be told whether it should honor EventFlagSilent, which only the app knows (it's the setting's state).

Without that app change we'd have to put the "Respect Phone DND" toggle on the watch itself, but since it only does anything on iOS that's an inconsistent product decision: the phone toggle acts only on Android (and is only shown on Android), whereas a watch toggle would show on both iOS and Android but act only on iOS. So I reused the existing Android setting and just sync its state to the watch, and the watch uses that to decide whether to act on EventFlagSilent when handling a notification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants