Skip to content

resources: re-encode Android day-cycle scenes as WebP (−1.4 MB, ~86%) - #48

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
MrL0:perf/android-scene-webp
Jul 8, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
MrL0:perf/android-scene-webp

Conversation

@MrL0

@MrL0 MrL0 commented Jul 7, 2026

Copy link
Copy Markdown

What & why

Android ships the same day-cycle scene backdrops (scene1…scene10, in res/drawable-nodpi/) as PNG — ~1.7 MB. Re-encoding as WebP (quality 85) drops that to ~0.24 MB — an 86% / ~1.4 MB reduction, with no code change: the drawables are referenced by name (R.drawable.sceneN via painterResource — SceneHeroBackground.kt), so the resource name is unchanged and Android decodes WebP natively.

WebP is the Android-native efficient image format (decode since API 14, first-class res/drawable resource, and what Android Studio's Convert to WebP… produces).

before (PNG) after (WebP)
10 scene drawables 1.7 MB 0.24 MB (−86%)

Companion to the iOS HEIC change in #46 — same win, platform-native format each side.

Verification

  • Pillow WebP encode, quality 85; resource names unchanged; Android CI (Gradle) builds it.
  • I can't render it here, so please eyeball a bright scene (scene10, midday) for sky banding — the hero backdrop is heavily dimmed/masked, so artifacts are invisible there.

Checklist

  • Assets only — no code changed
  • No new build warnings expected (name-referenced drawables; unchanged names)
  • UI changes use only StrandDesign tokens — n/a
  • No hardcoded hex frame bytes — n/a

Re-encoded via an AI-assisted pass; resource names verified and size measured locally. Please sanity-check rendering on a bright scene.

The same scene1..scene10 backdrops in res/drawable-nodpi shipped as PNG (~1.7MB). Re-encode as WebP (quality 85) -> ~0.24MB, an 86% reduction, no code change: drawables are referenced by name (R.drawable.sceneN via painterResource in SceneHeroBackground.kt) so the resource name is unchanged and Android decodes WebP natively. Companion to the iOS HEIC change (#46).
@ryanbr
ryanbr merged commit b77f376 into ryanbr:main Jul 8, 2026
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
resources: re-encode Android day-cycle scenes as WebP (−1.4 MB, ~86%)
ryanbr added a commit that referenced this pull request Jul 27, 2026
A COMMAND_RESPONSE carries three things: which command it answers, the origin-seq
echo, and the result code. Kotlin publishes all three — on 5/MG since the port,
on 4.0 since #791. Swift published only the first, on either family. An Apple
strap log named the command and then said nothing about whether it succeeded.

That is not only a display gap. The result code is the accept/reject signal:
UNSUPPORTED(3) is how the 5/MG haptics rejection was identified (#48), FAILURE(0)
on an empty extended-battery reply is the whole finding in #791, and SUCCESS(1)
is the entire evidence column in #891. Because the interpreter did not expose it,
every Swift probe that needed it re-derived the byte privately —
FeatureFlagProbe.resultLabel, DeviceConfigReadProbe via that, and
BodyLocationProbe's own "Result code @12" — three copies of one two-line decode.

Decoded in the two post-hooks rather than as schema fields. Adding
`resp_seq`/`result` to the COMMAND_RESPONSE field list would have been a smaller
diff and would have picked up the +4 shift for 5/MG automatically, but the
schema-field loop reads at a raw frame offset with no payload bound, so a
response whose declared payload ends before offset 8 would have reported its
CRC32 trailer as a result code. The hooks already compute the bounded payload
slice with exactly the guard Kotlin uses, so both fields are read from there and
a short reply decodes neither.

`CommandResult` does go in the shared schema, so `enumName` renders "SUCCESS(1)"
and falls back to "0x7F(127)" — byte-identical to the Kotlin twin's
commandResultLabel/hexLabel, which is what makes logs from the two platforms
comparable rather than merely both present.

Tests: `Whoop4ResponseResultTests` is the Swift twin of the Kotlin file of the
same name and reuses its real #791 captures byte for byte, so the two suites now
assert the same decode of the same frames — including the duplicate-write case
where one send produced three responses sharing one origin-seq echo. Adds a
fail-closed case (declared payload ends before the result byte -> neither field)
and an unknown-code case (0x7F stays hex, no invented name). The #891 MG toggle
fixtures now assert resp_seq and result too, which is what #893 had to leave out
because this decoder did not produce them; the battery fixture pins that the new
fields did not shift the offsets its 47% value is read from.

The Python-golden parity corpus gains the two fields on its one COMMAND_RESPONSE
entry. Updating it surfaced a counterexample to the #791 mapping itself: that
frame is a real GET_BATTERY_LEVEL reply carrying a valid 42.5%, and its
payload[1] is 0x00 — so it now reads FAILURE(0) on a reply that plainly
succeeded. Kotlin's own battery fixture (91.2%) has the same shape, and Android
has rendered it that way since #791, so this is upstream of this change rather
than introduced by it. Filed as #900 and asserted meanwhile in
testSuccessfulBatteryReadStillReportsResultZero, so the behaviour is deliberate
and a future correction has to come past a test instead of silently changing a
log.

Verified: schema and golden JSON parse, offsets land on Kotlin's (4.0 @7/@8,
5/MG @11/@12), CRCs of both reused 4.0 fixtures re-verified independently, and
the golden reserializes byte-identically apart from the intended entry. `fb.add`
populates `parsed` on both decode paths, so FastPathParityTests' fast==full
contract still holds. No BLE write path changes. Swift cannot be built on this
host (no libc6-dev), so the suite runs in CI. doc_comment_lint and i18n_audit
pass.
ryanbr added a commit that referenced this pull request Jul 27, 2026
A COMMAND_RESPONSE carries three things: which command it answers, the origin-seq
echo, and the result code. Kotlin publishes all three — on 5/MG since the port,
on 4.0 since #791. Swift published only the first, on either family. An Apple
strap log named the command and then said nothing about whether it succeeded.

That is not only a display gap. The result code is the accept/reject signal:
UNSUPPORTED(3) is how the 5/MG haptics rejection was identified (#48), FAILURE(0)
on an empty extended-battery reply is the whole finding in #791, and SUCCESS(1)
is the entire evidence column in #891. Because the interpreter did not expose it,
every Swift probe that needed it re-derived the byte privately —
FeatureFlagProbe.resultLabel, DeviceConfigReadProbe via that, and
BodyLocationProbe's own "Result code @12" — three copies of one two-line decode.

Decoded in the two post-hooks rather than as schema fields. Adding
`resp_seq`/`result` to the COMMAND_RESPONSE field list would have been a smaller
diff and would have picked up the +4 shift for 5/MG automatically, but the
schema-field loop reads at a raw frame offset with no payload bound, so a
response whose declared payload ends before offset 8 would have reported its
CRC32 trailer as a result code. The hooks already compute the bounded payload
slice with exactly the guard Kotlin uses, so both fields are read from there and
a short reply decodes neither.

`CommandResult` does go in the shared schema, so `enumName` renders "SUCCESS(1)"
and falls back to "0x7F(127)" — byte-identical to the Kotlin twin's
commandResultLabel/hexLabel, which is what makes logs from the two platforms
comparable rather than merely both present.

Tests: `Whoop4ResponseResultTests` is the Swift twin of the Kotlin file of the
same name and reuses its real #791 captures byte for byte, so the two suites now
assert the same decode of the same frames — including the duplicate-write case
where one send produced three responses sharing one origin-seq echo. Adds a
fail-closed case (declared payload ends before the result byte -> neither field)
and an unknown-code case (0x7F stays hex, no invented name). The #891 MG toggle
fixtures now assert resp_seq and result too, which is what #893 had to leave out
because this decoder did not produce them; the battery fixture pins that the new
fields did not shift the offsets its 47% value is read from.

The Python-golden parity corpus gains the two fields on its one COMMAND_RESPONSE
entry. Updating it surfaced a counterexample to the #791 mapping itself: that
frame is a real GET_BATTERY_LEVEL reply carrying a valid 42.5%, and its
payload[1] is 0x00 — so it now reads FAILURE(0) on a reply that plainly
succeeded. Kotlin's own battery fixture (91.2%) has the same shape, and Android
has rendered it that way since #791, so this is upstream of this change rather
than introduced by it. Filed as #900 and asserted meanwhile in
testSuccessfulBatteryReadStillReportsResultZero, so the behaviour is deliberate
and a future correction has to come past a test instead of silently changing a
log.

Verified: schema and golden JSON parse, offsets land on Kotlin's (4.0 @7/@8,
5/MG @11/@12), CRCs of both reused 4.0 fixtures re-verified independently, and
the golden reserializes byte-identically apart from the intended entry. `fb.add`
populates `parsed` on both decode paths, so FastPathParityTests' fast==full
contract still holds. No BLE write path changes. Swift cannot be built on this
host (no libc6-dev), so the suite runs in CI. doc_comment_lint and i18n_audit
pass.
ryanbr added a commit that referenced this pull request Jul 27, 2026
…) (#899)

A COMMAND_RESPONSE carries three things: which command it answers, the origin-seq
echo, and the result code. Kotlin publishes all three — on 5/MG since the port,
on 4.0 since #791. Swift published only the first, on either family. An Apple
strap log named the command and then said nothing about whether it succeeded.

That is not only a display gap. The result code is the accept/reject signal:
UNSUPPORTED(3) is how the 5/MG haptics rejection was identified (#48), FAILURE(0)
on an empty extended-battery reply is the whole finding in #791, and SUCCESS(1)
is the entire evidence column in #891. Because the interpreter did not expose it,
every Swift probe that needed it re-derived the byte privately —
FeatureFlagProbe.resultLabel, DeviceConfigReadProbe via that, and
BodyLocationProbe's own "Result code @12" — three copies of one two-line decode.

Decoded in the two post-hooks rather than as schema fields. Adding
`resp_seq`/`result` to the COMMAND_RESPONSE field list would have been a smaller
diff and would have picked up the +4 shift for 5/MG automatically, but the
schema-field loop reads at a raw frame offset with no payload bound, so a
response whose declared payload ends before offset 8 would have reported its
CRC32 trailer as a result code. The hooks already compute the bounded payload
slice with exactly the guard Kotlin uses, so both fields are read from there and
a short reply decodes neither.

`CommandResult` does go in the shared schema, so `enumName` renders "SUCCESS(1)"
and falls back to "0x7F(127)" — byte-identical to the Kotlin twin's
commandResultLabel/hexLabel, which is what makes logs from the two platforms
comparable rather than merely both present.

Tests: `Whoop4ResponseResultTests` is the Swift twin of the Kotlin file of the
same name and reuses its real #791 captures byte for byte, so the two suites now
assert the same decode of the same frames — including the duplicate-write case
where one send produced three responses sharing one origin-seq echo. Adds a
fail-closed case (declared payload ends before the result byte -> neither field)
and an unknown-code case (0x7F stays hex, no invented name). The #891 MG toggle
fixtures now assert resp_seq and result too, which is what #893 had to leave out
because this decoder did not produce them; the battery fixture pins that the new
fields did not shift the offsets its 47% value is read from.

The Python-golden parity corpus gains the two fields on its one COMMAND_RESPONSE
entry. Updating it surfaced a counterexample to the #791 mapping itself: that
frame is a real GET_BATTERY_LEVEL reply carrying a valid 42.5%, and its
payload[1] is 0x00 — so it now reads FAILURE(0) on a reply that plainly
succeeded. Kotlin's own battery fixture (91.2%) has the same shape, and Android
has rendered it that way since #791, so this is upstream of this change rather
than introduced by it. Filed as #900 and asserted meanwhile in
testSuccessfulBatteryReadStillReportsResultZero, so the behaviour is deliberate
and a future correction has to come past a test instead of silently changing a
log.

Verified: schema and golden JSON parse, offsets land on Kotlin's (4.0 @7/@8,
5/MG @11/@12), CRCs of both reused 4.0 fixtures re-verified independently, and
the golden reserializes byte-identically apart from the intended entry. `fb.add`
populates `parsed` on both decode paths, so FastPathParityTests' fast==full
contract still holds. No BLE write path changes. Swift cannot be built on this
host (no libc6-dev), so the suite runs in CI. doc_comment_lint and i18n_audit
pass.
ryanbr added a commit that referenced this pull request Jul 28, 2026
WhoopBleClient logs every non-SUCCESS command response, which is how the MG
haptics rejection (#48) would surface in-app. But every WHOOP 4.0
GET_BATTERY_LEVEL reply on record carries a zeroed [seq][result] prefix, so a
battery read that returned a perfectly good percentage logs as:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0)

next to a battery gauge showing 42%. A log line asserting a failure that did not
happen is exactly the artefact #900 exists to warn about — that issue began when
a FAILURE(0) was read as evidence about firmware, and this line manufactures the
same misreading on every battery read a 4.0 owner makes.

The line still prints. Suppressing it would hide the anomaly, and the anomaly is
the thing #900 wants a capture of. It is annotated instead, when the same frame
also decoded a value:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0) (the reply still
    carried a value: battery 42.5% — see #900, the 4.0 result byte is not
    established)

A genuine failure that carried no value — the extended-battery case from #791,
the UNSUPPORTED rejections — is untouched, because the annotation is gated on a
decoded value being present.

This does not decide what the 4.0 result byte means; #900 still wants one real
capture of known provenance. It stops the log asserting an answer in the
meantime, the same discipline as #913, #914 and #918.

Android-only: the Swift side has no command-response result consumer, so there
is no parity twin to change. No test — the string is inside a class that needs
the Android framework, and the annotation is a log line rather than behaviour;
the rendered output was checked against both real 4.0 battery fixtures instead.
ryanbr added a commit that referenced this pull request Jul 28, 2026
WhoopBleClient logs every non-SUCCESS command response, which is how the MG
haptics rejection (#48) would surface in-app. But every WHOOP 4.0
GET_BATTERY_LEVEL reply on record carries a zeroed [seq][result] prefix, so a
battery read that returned a perfectly good percentage logs as:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0)

next to a battery gauge showing 42%. A log line asserting a failure that did not
happen is exactly the artefact #900 exists to warn about — that issue began when
a FAILURE(0) was read as evidence about firmware, and this line manufactures the
same misreading on every battery read a 4.0 owner makes.

The line still prints. Suppressing it would hide the anomaly, and the anomaly is
the thing #900 wants a capture of. It is annotated instead, when the same frame
also decoded a value:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0) (the reply still
    carried a value: battery 42.5% — see #900, the 4.0 result byte is not
    established)

A genuine failure that carried no value — the extended-battery case from #791,
the UNSUPPORTED rejections — is untouched, because the annotation is gated on a
decoded value being present.

This does not decide what the 4.0 result byte means; #900 still wants one real
capture of known provenance. It stops the log asserting an answer in the
meantime, the same discipline as #913, #914 and #918.

Android-only: the Swift side has no command-response result consumer, so there
is no parity twin to change. No test — the string is inside a class that needs
the Android framework, and the annotation is a log line rather than behaviour;
the rendered output was checked against both real 4.0 battery fixtures instead.
ryanbr added a commit that referenced this pull request Aug 8, 2026
…1115) (#1159)

* feat(android): opt-in wrist buzz for the phone's native timer/alarm (#1115)

A dedicated toggle so NOOP buzzes the strap when your phone's Clock app fires a
timer or alarm — the natively-set timer/alarm you already use, not a NOOP one.

The existing per-app notification path can already buzz on a Clock notification
if you enable that app, but a RINGING alarm posts an ONGOING notification that
path deliberately skips. So catch it earlier by `CATEGORY_ALARM` (matches any
clock app, no package list), gated by a new `notif.alarmTimer` toggle (default
OFF) under the existing wrist-alerts master + quiet-hours + only-when-worn. It
only intercepts when the toggle is on — otherwise a Clock notification falls
through to the per-app path unchanged, so nothing regresses — and returns after
buzzing so it can't also double-buzz via per-app.

Android-only by nature: iOS/macOS can't observe another app's notifications (no
NotificationListenerService), the same ceiling as calls + per-app buzzes — so
there is no Apple twin. A "Timer & alarm" toggle is added to the Android
Notifications screen (localized in values + de/es/fr/pt-PT/zh).

Verified: compileFullDebugKotlin + i18n_audit --ci main clean. BLE buzz path is
the existing RUN_HAPTICS_PATTERN (works on 4.0; 5/MG haptic unhonoured, #48);
not yet hardware-tested against a real Clock alarm.

* fix(android): dedup the timer/alarm buzz so a re-posting notification can't storm (re-review)

Re-review catch: onNotificationPosted fires on every post AND update, and a
ringing alarm (or a running-timer notification) re-posts repeatedly — so the
naive buzz-on-every-post would buzz-storm the strap. CallAlertController solves
this for calls with a throttled loop + token dedup; mirror the intent minimally
here by buzzing at most ONCE per notification key (recorded only when we actually
buzz, cleared in onNotificationRemoved so a genuine re-ring buzzes again). Also
makes the 'buzzes once' help text honest, and downgrades a possible wrong-timing
buzz from a storm to a single tap.

* fix(android): don't self-buzz on NOOP's own smart-alarm notification (re-review)

Re-review catch: NOOP's OWN SmartAlarmNotifier + SmartAlarmReceiver set
CATEGORY_ALARM, and a NotificationListenerService receives its own app's posts —
so the new Timer & alarm toggle would also buzz for NOOP's own smart alarm (a
surprise for a 'phone Clock' toggle, and a double-buzz with its firmware-alarm
path). Exclude our own package (ctx.packageName, robust across .debug/.staging/
.demo flavors); NOOP's alarm keeps its own settings + firmware/backup path
untouched. An app-buzz for NOOP's own alarm remains a separate, deliberate idea.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…anbr#894) (ryanbr#899)

A COMMAND_RESPONSE carries three things: which command it answers, the origin-seq
echo, and the result code. Kotlin publishes all three — on 5/MG since the port,
on 4.0 since ryanbr#791. Swift published only the first, on either family. An Apple
strap log named the command and then said nothing about whether it succeeded.

That is not only a display gap. The result code is the accept/reject signal:
UNSUPPORTED(3) is how the 5/MG haptics rejection was identified (ryanbr#48), FAILURE(0)
on an empty extended-battery reply is the whole finding in ryanbr#791, and SUCCESS(1)
is the entire evidence column in ryanbr#891. Because the interpreter did not expose it,
every Swift probe that needed it re-derived the byte privately —
FeatureFlagProbe.resultLabel, DeviceConfigReadProbe via that, and
BodyLocationProbe's own "Result code @12" — three copies of one two-line decode.

Decoded in the two post-hooks rather than as schema fields. Adding
`resp_seq`/`result` to the COMMAND_RESPONSE field list would have been a smaller
diff and would have picked up the +4 shift for 5/MG automatically, but the
schema-field loop reads at a raw frame offset with no payload bound, so a
response whose declared payload ends before offset 8 would have reported its
CRC32 trailer as a result code. The hooks already compute the bounded payload
slice with exactly the guard Kotlin uses, so both fields are read from there and
a short reply decodes neither.

`CommandResult` does go in the shared schema, so `enumName` renders "SUCCESS(1)"
and falls back to "0x7F(127)" — byte-identical to the Kotlin twin's
commandResultLabel/hexLabel, which is what makes logs from the two platforms
comparable rather than merely both present.

Tests: `Whoop4ResponseResultTests` is the Swift twin of the Kotlin file of the
same name and reuses its real ryanbr#791 captures byte for byte, so the two suites now
assert the same decode of the same frames — including the duplicate-write case
where one send produced three responses sharing one origin-seq echo. Adds a
fail-closed case (declared payload ends before the result byte -> neither field)
and an unknown-code case (0x7F stays hex, no invented name). The ryanbr#891 MG toggle
fixtures now assert resp_seq and result too, which is what ryanbr#893 had to leave out
because this decoder did not produce them; the battery fixture pins that the new
fields did not shift the offsets its 47% value is read from.

The Python-golden parity corpus gains the two fields on its one COMMAND_RESPONSE
entry. Updating it surfaced a counterexample to the ryanbr#791 mapping itself: that
frame is a real GET_BATTERY_LEVEL reply carrying a valid 42.5%, and its
payload[1] is 0x00 — so it now reads FAILURE(0) on a reply that plainly
succeeded. Kotlin's own battery fixture (91.2%) has the same shape, and Android
has rendered it that way since ryanbr#791, so this is upstream of this change rather
than introduced by it. Filed as ryanbr#900 and asserted meanwhile in
testSuccessfulBatteryReadStillReportsResultZero, so the behaviour is deliberate
and a future correction has to come past a test instead of silently changing a
log.

Verified: schema and golden JSON parse, offsets land on Kotlin's (4.0 @7/@8,
5/MG @11/@12), CRCs of both reused 4.0 fixtures re-verified independently, and
the golden reserializes byte-identically apart from the intended entry. `fb.add`
populates `parsed` on both decode paths, so FastPathParityTests' fast==full
contract still holds. No BLE write path changes. Swift cannot be built on this
host (no libc6-dev), so the suite runs in CI. doc_comment_lint and i18n_audit
pass.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…anbr#923)

WhoopBleClient logs every non-SUCCESS command response, which is how the MG
haptics rejection (ryanbr#48) would surface in-app. But every WHOOP 4.0
GET_BATTERY_LEVEL reply on record carries a zeroed [seq][result] prefix, so a
battery read that returned a perfectly good percentage logs as:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0)

next to a battery gauge showing 42%. A log line asserting a failure that did not
happen is exactly the artefact ryanbr#900 exists to warn about — that issue began when
a FAILURE(0) was read as evidence about firmware, and this line manufactures the
same misreading on every battery read a 4.0 owner makes.

The line still prints. Suppressing it would hide the anomaly, and the anomaly is
the thing ryanbr#900 wants a capture of. It is annotated instead, when the same frame
also decoded a value:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0) (the reply still
    carried a value: battery 42.5% — see ryanbr#900, the 4.0 result byte is not
    established)

A genuine failure that carried no value — the extended-battery case from ryanbr#791,
the UNSUPPORTED rejections — is untouched, because the annotation is gated on a
decoded value being present.

This does not decide what the 4.0 result byte means; ryanbr#900 still wants one real
capture of known provenance. It stops the log asserting an answer in the
meantime, the same discipline as ryanbr#913, ryanbr#914 and ryanbr#918.

Android-only: the Swift side has no command-response result consumer, so there
is no parity twin to change. No test — the string is inside a class that needs
the Android framework, and the annotation is a log line rather than behaviour;
the rendered output was checked against both real 4.0 battery fixtures instead.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…yanbr#1115) (ryanbr#1159)

* feat(android): opt-in wrist buzz for the phone's native timer/alarm (ryanbr#1115)

A dedicated toggle so NOOP buzzes the strap when your phone's Clock app fires a
timer or alarm — the natively-set timer/alarm you already use, not a NOOP one.

The existing per-app notification path can already buzz on a Clock notification
if you enable that app, but a RINGING alarm posts an ONGOING notification that
path deliberately skips. So catch it earlier by `CATEGORY_ALARM` (matches any
clock app, no package list), gated by a new `notif.alarmTimer` toggle (default
OFF) under the existing wrist-alerts master + quiet-hours + only-when-worn. It
only intercepts when the toggle is on — otherwise a Clock notification falls
through to the per-app path unchanged, so nothing regresses — and returns after
buzzing so it can't also double-buzz via per-app.

Android-only by nature: iOS/macOS can't observe another app's notifications (no
NotificationListenerService), the same ceiling as calls + per-app buzzes — so
there is no Apple twin. A "Timer & alarm" toggle is added to the Android
Notifications screen (localized in values + de/es/fr/pt-PT/zh).

Verified: compileFullDebugKotlin + i18n_audit --ci main clean. BLE buzz path is
the existing RUN_HAPTICS_PATTERN (works on 4.0; 5/MG haptic unhonoured, ryanbr#48);
not yet hardware-tested against a real Clock alarm.

* fix(android): dedup the timer/alarm buzz so a re-posting notification can't storm (re-review)

Re-review catch: onNotificationPosted fires on every post AND update, and a
ringing alarm (or a running-timer notification) re-posts repeatedly — so the
naive buzz-on-every-post would buzz-storm the strap. CallAlertController solves
this for calls with a throttled loop + token dedup; mirror the intent minimally
here by buzzing at most ONCE per notification key (recorded only when we actually
buzz, cleared in onNotificationRemoved so a genuine re-ring buzzes again). Also
makes the 'buzzes once' help text honest, and downgrades a possible wrong-timing
buzz from a storm to a single tap.

* fix(android): don't self-buzz on NOOP's own smart-alarm notification (re-review)

Re-review catch: NOOP's OWN SmartAlarmNotifier + SmartAlarmReceiver set
CATEGORY_ALARM, and a NotificationListenerService receives its own app's posts —
so the new Timer & alarm toggle would also buzz for NOOP's own smart alarm (a
surprise for a 'phone Clock' toggle, and a double-buzz with its firmware-alarm
path). Exclude our own package (ctx.packageName, robust across .debug/.staging/
.demo flavors); NOOP's alarm keeps its own settings + firmware/backup path
untouched. An app-buzz for NOOP's own alarm remains a separate, deliberate idea.
Zebsi235 pushed a commit to Zebsi235/noop that referenced this pull request Sep 6, 2026
…wers

GET_BATTERY_PACK_INFO (151) is now polled on the keep-alive cadence for a 5/MG
(WhoopBleClient keepalive, WHOOP5 branch). On a WHOOP MG it will not be answered.

This adds a user-triggered argument SWEEP behind the same read-only,
Test-Centre-gated shape as the ryanbr#592 (opcode 98) and ryanbr#690 (opcode 84) probes: a
menu entry on the Devices card, a confirmation dialog, five bounded sends ~900 ms
apart, and each result code logged. It feeds no live state and gates nothing. The
sweep is the difference from the poll, which sends one form -- varying the
argument is what turned "151 fails" into the sharper claim below.

Measured on WHOOP MG fw 50.39.1.0:

  - 151 answers FAILURE(0) with the pack attached and ACTIVELY CHARGING (proven
    independently by the strap's own gauge rising 80.0 -> 82.2 over two minutes
    while the probe ran), with the pack removed, and across five argument forms
  - it PARSES the argument: payload first byte 0x00 -> FAILURE, 0x01 -> no reply
    at all. If the argument were ignored the two would be identical
  - opcode 98 answers UNSUPPORTED(3) on the same strap in the same session

That last line is the finding. commandResultLabel separates 0=FAILURE from
3=UNSUPPORTED, and UNSUPPORTED is hardware-confirmed on this very MG by the ryanbr#48
haptics rejection. The firmware has a way to say "I do not implement that opcode"
and did not use it for 151. So 151 IS implemented here and is refusing the
REQUEST, not the opcode.

My first reading of this was "151 is unsupported on MG, the feature is dead",
drawn from five consistent FAILUREs without checking what this firmware's other
refusals look like. That was wrong, and the comparison above is why.

Does NOT add the CommandNumber entry -- upstream already has it.

Does NOT argue the poll should be removed. One device, one firmware: nothing here
establishes whether a non-MG WHOOP 5 answers 151, and the value of this sweep is
that such an owner can now produce exactly that capture. The golden vectors behind
BatteryPackInfo came from a WHOOP 5, not an MG.

Pack charge on an MG is not blocked by any of this: the strap volunteers the same
record in pushed event 0x6D (109), which is a separate change. This is RE
instrumentation, not a feature.

Strings carry ru and zh alongside the focus locales, so the extra-locale gate
sees no new gaps. The ru wording follows the existing ryanbr#592/ryanbr#690 probe strings
closely; the zh is mine and unreviewed -- its neighbouring probe strings are
untranslated, so there was no house style to follow. Chosen over raising
i18n_extra_locale_baseline, which only ratchets down.
Zebsi235 pushed a commit to Zebsi235/noop that referenced this pull request Sep 6, 2026
…MG answers

GET_BATTERY_PACK_INFO (151) is now polled on the keep-alive cadence for a 5/MG
(ryanbr#1303). On a WHOOP MG it is not answered.

This adds a user-triggered probe in the same read-only, Test-Centre-gated shape
as the ryanbr#592 (opcode 98) and ryanbr#690 (opcode 84) probes: a menu entry on the Devices
card, a confirmation dialog, ONE command per press, and the decoded reply in a
dialog and the strap log. A timeout turns silence into a stated verdict rather
than an absent line. It feeds no live state and gates nothing.

Does NOT add the CommandNumber entry -- ryanbr#1303 already has it.

Measured on WHOOP MG fw 50.39.1.0:

  - 151 answers FAILURE(0) with the pack attached and ACTIVELY CHARGING (proven
    independently by the strap's own gauge rising 80.0 -> 82.2 over two minutes
    while the probe ran), and with the pack removed
  - opcode 98 answers UNSUPPORTED(3) on the same strap in the same session

That last line is the finding. commandResultLabel separates 0=FAILURE from
3=UNSUPPORTED, and UNSUPPORTED is hardware-confirmed on this very MG by the ryanbr#48
haptics rejection. The firmware has a way to say "I do not implement that opcode"
and did not use it for 151. So 151 IS implemented here and is refusing the
REQUEST, not the opcode.

Separately, and NOT part of this change: a local research build that varied the
argument showed the firmware parses it -- payload first byte 0x00 gives FAILURE,
0x01 gives no reply at all, across five forms and three runs. That argument-sweep
scaffolding is deliberately not shipped here; this probe sends the default form,
one command per press, matching the probes it sits beside. The observation is
recorded because it narrows what a future capture should try, not because this
code produces it.

My first reading was "151 is unsupported on MG, the feature is dead", drawn from
consistent FAILUREs without checking what this firmware's other refusals look
like. That was wrong, and the comparison above is why.

Does NOT argue the poll should be removed. One device, one firmware: nothing here
establishes whether a non-MG WHOOP 5 answers 151, and the value of the probe is
that such an owner can produce that capture. The golden vectors behind
BatteryPackInfo came from a WHOOP 5, not an MG.

Pack charge on an MG is not blocked by any of this: the strap volunteers the same
record in pushed event 0x6D (109), which is a separate change. This is RE
instrumentation, not a feature.

Strings carry ru and zh alongside the focus locales, so the extra-locale gate sees
no new gaps. The ru wording follows the existing ryanbr#592/ryanbr#690 probe strings closely;
the zh is mine and unreviewed -- its neighbouring probe strings are untranslated,
so there was no house style to follow.
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