Skip to content

iOS: liquid Today honours the Day-cycle background toggle (closes #16 parity gap) - #17

Merged
ryanbr merged 1 commit into
mainfrom
ios-today-daycycle-gate
Jul 7, 2026
Merged

ryanbr merged 1 commit into
mainfrom
ios-today-daycycle-gate

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Closes the one parity gap flagged in #16. On iOS, LiquidTodayView always drew the sky, ignoring the "Day-cycle background" setting — so turning it off did not give the plain dark canvas it does on Android (and the classic TodayView). That also made the "Sky behind cards" switch standalone on iOS vs. day-cycle-gated on Android.

Changes

  • Gate the liquid Today sky on @AppStorage(SceneBackgroundPrefs.enabledKey) — off = the plain surfaceBase canvas (parity with Android + classic Today).
  • Disable/grey the "Sky behind cards" toggle when the day-cycle background is off (the sky it extends isn\t drawn then) — mirrors Android\s enabled = showDayCycleBackground.

Result — full parity

day-cycle sky-behind-cards Android iOS (after)
ON OFF hero-band sky hero-band sky
ON ON full sky behind cards full sky behind cards
OFF — plain canvas, toggle disabled plain canvas, toggle disabled

Default behaviour unchanged (day-cycle defaults ON). Swift compile-gated by CI.

Closes the last parity gap from #16. iOS`\s LiquidTodayView always drew the sky,
ignoring the "Day-cycle background" setting — so turning it off did NOT give the plain
dark canvas it does on Android and the classic TodayView. The "Sky behind cards" switch
was therefore standalone on iOS vs day-cycle-gated on Android.

- Gate the LiquidTodayView sky on @AppStorage(SceneBackgroundPrefs.enabledKey); off = the
  plain surfaceBase canvas (matches Android + classic Today).
- Disable/grey the "Sky behind cards" Settings toggle when the day-cycle background is off
  (the sky it extends is not drawn then) — mirrors the Android `enabled = showDayCycleBackground`.
@ryanbr
ryanbr merged commit 54e8c24 into main Jul 7, 2026
4 checks passed
@ryanbr
ryanbr deleted the ios-today-daycycle-gate branch July 14, 2026 10:49
DX23876 referenced this pull request in DX23876/noop Jul 22, 2026
iOS: liquid Today honours the Day-cycle background toggle (closes #16 parity gap)
ryanbr added a commit that referenced this pull request Sep 7, 2026
…on the link (#1950)

Groundwork for #1949. Nothing is gated and no behaviour changes; this makes a
5/MG log answerable.

## What a capture could not tell you

An MG log showed the four puffin notify chars discovered, one standard-HR
subscribe, and nothing further:

```
[connection]   service fd4b0001-cce1-4033-93ce-002d5875f58a (5 char)
[connection]     fd4b0003-... props=0x10 (Notify)
[connection]     fd4b0004-... props=0x10 (Notify)
[connection]     fd4b0005-... props=0x10 (Notify)
[connection]     fd4b0007-... props=0x10 (Notify)
Subscribed 00002a37-0000-1000-8000-00805f9b34fb
```

Reading that, there is no way to tell the app declined to subscribe them from
the strap refusing. Those are opposite answers for #1635: one is a setting, the
other is the verdict the probe exists to obtain. Both silences are now spoken.

## The probe's skip reason

`beginUnbondedOffloadProbe` returned silently whenever `shouldProbeUnbondedOffload`
said no. It now names which of the seven conditions decided, in the gate's own
order so the reason printed is the one that actually decided rather than the
first that happens to be true. Once per link, because the path is retried.

The common case is the actionable one:

```
unbonded probe skipped — the unbonded-offload experiment is off. The puffin notify
chars stay unsubscribed on this link, so neither the historical offload nor a
realtime IMU producer can reach us here.
```

The consequence clause is appended only for an unbonded 5/MG, since a bonded
strap reaches those chars through the ordinary handshake and the sentence would
be false there.

## The pairing dump

`gattTreeLines` says what the strap OFFERS. This says what we HOLD with it: bond
state, `didBond`, whether a hello went out, whether the probe is opted in, and
per notify characteristic whether it carries a CCCD.

It reports POSTURE, not outcome. A first draft of this carried a `subscribed`
column and a re-review removed it, because it could not have been honest: the
dump runs at service discovery, before the CCCD queue is drained, so every
subscription is still ahead of it, and on API 33+ it could not have been read
anyway since `writeDescriptor(descriptor, value)` never populates
`descriptor.value`. It would have printed `subscribed=no` universally, including
for a probe that HAD subscribed the puffin chars, which is the one case this
exists to observe. Those outcomes are already in the log as one confirmed
`Subscribed <uuid>` line each, so the note points a reader at them and says what
their absence means. A test pins the column's absence with that reasoning
attached.

What that absence MEANS flips with the opt-in, and a later pass caught the note
asserting one half of it unconditionally. With the probe off, only
`HEART_RATE_CHAR` and `BATTERY_CHAR` reach `cccdQueue`, so an unsubscribed puffin
char is ours. With the probe ON it subscribes those same chars itself, so a
missing line there is the STRAP refusing, which is the opposite conclusion and
the whole reason the probe is run. The note branches, and both branches are
pinned with each asserting the other's wording is absent.

On encryption it deliberately claims less than a reader might want. Android
publishes no link-encryption flag to a GATT client, and a remote
characteristic's permissions read back as 0, so bond state is a proxy and not
the same question. The note names the two CCCD write statuses that do answer it,
5 and 15, which is what the probe already listens for. A test pins that wording
so a later edit cannot quietly upgrade it to a claim the code cannot make.

`bondStateName` is `BondStateTrace`'s, not a second spelling: two renderings of
one OS state in one log is how a reader ends up believing they are two readings.

## Tests

The explanation and the gate are pinned against each other EXHAUSTIVELY, over
every combination of the seven inputs. They are separate `when` chains over the
same conditions, and the failure mode is a condition added to one and forgotten
in the other, which no hand-picked case is likely to sit on. Plus order,
consequence-clause suppression, the two retirement kinds, and the dump's shape.

The gate's inputs are read ONCE and the same values handed to the explanation.
They are `@Volatile` fields and a live pref read, so re-reading them could name a
reason that was not the one that decided.

Android suite 5520 green, doc-comment gate clean.

## Scope

Android only, and the twinned file already stated the rule. `GattCapability` has
a Swift half, so an untwinned function in it deserves an answer, and its header
gives one: `gattTreeLines` was twinned because CoreBluetooth exposes services,
characteristics and properties, "unlike the bond-state and pairing helpers, which
have no Apple equivalent at all". The pairing dump is one of those, keyed on an
OS bond state CoreBluetooth does not publish and on an Android-only probe's
opt-in. The skip reason has nothing to twin either: there is no iOS probe whose
silence needs explaining. That rationale is now recorded at the function rather
than left to be re-derived.

Worth recording, because it runs opposite to the guess: iOS is the platform that
COULD carry the subscription column removed above. `CBCharacteristic.isNotifying`
is authoritative where Android's `descriptor.value` is null on API 33+, and iOS
already re-subscribes the puffin chars post-bond precisely because the strap
refuses them until the link is encrypted (issue #17). An iOS equivalent would be
a different and slightly better instrument, emitted after the subscribe attempt.

The two lines have deliberately different visibility. The domain argument to
`log()` is a TAG, not a gate, so the skip reason lands in an ordinary strap log:
one line, only on a no-hello 5/MG link, explaining a silence to whoever reads the
log they already have. The dump is eight lines of readout and stays behind the
Test Centre connection domain.

Local reads on already-discovered state, gated to the Test Centre connection
domain. No GATT operation and no traffic, which is the same discipline
`gattTreeLines` was written to, and the reason both work on exactly the strap
that no puffin probe can reach.
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.

1 participant