Skip to content

Fix: AirPods data no longer freezes on Android 10/11#578

Merged
d4rken merged 1 commit into
mainfrom
fix/devicemonitor-persist-crash
May 3, 2026
Merged

Fix: AirPods data no longer freezes on Android 10/11#578
d4rken merged 1 commit into
mainfrom
fix/devicemonitor-persist-crash

Conversation

@d4rken

@d4rken d4rken commented May 3, 2026

Copy link
Copy Markdown
Member

What changed

Fixed a bug where the app would silently stop updating AirPods data (battery levels, in-ear status, case-open popup, widgets) on some Android 10 and 11 devices. Once the bug triggered, every screen and widget would freeze on the last value seen until the app was force-quit.

Also rolled into the same fix:

  • DeviceInfo updates (firmware version, marketing version, per-earbud serials) are now persisted even when no fresh battery reading is available.
  • Persistence errors can no longer silently freeze the data flow — failures are caught, logged, and reported once per device.

Technical Context

  • Root cause: existing.percent == livePercent in mergeBatterySlot compiled to Intrinsics.areEqual(Float, Float) because livePercent: Float? retains JVM type java.lang.Float even after the smart cast. R8 inlined areEqual and dropped the second-operand null check on Android 10/11's interpreter+JIT path, NPE'ing inside the supposedly null-safe helper.
  • The throw escaped through liveState.onEach { persistLiveDevices(...) }, cancelling the upstream combine. replayingShare's WhileSubscribed(Duration.ZERO) then re-ran the deterministic crash on every restart, so observers were left stuck on the cached replay=1 value.
  • Fix: mergeBatterySlot now Elvis-unboxes Float? to primitive float; slot percent comparisons in hasStateChanged are routed through a new hasSlotChanged helper that compares primitive float directly. Verified at the dex bytecode level via apkanalyzer: both methods emit cmpg-float with no Intrinsics.areEqual invocation in the merge path.
  • hasStateChanged also now compares leftEarbudSerial / rightEarbudSerial / marketingVersion (added in #32ff7e5b but never wired into dedup), and toCachedState no longer skips when only DeviceInfo is fresh. To make that path actually fire, liveState.onEach now also persists AAP-only profiles (active AAP, no BLE in this scan).
  • The new try/catch in persistLiveDevices is defense-in-depth: any future merge-path bug logs and reports once per profileId instead of cancelling the upstream flow.

Review checklist

  • mergeBatterySlot and hasSlotChanged show cmpg-float and no Intrinsics.areEqual in the dex bytecode of assembleFossRelease
  • AAP-only persistence in liveState.onEach doesn't double-write or feedback-loop against the existing nonLiveDevices synthesis in the devices flow
  • reportedPersistFailures dedup is acceptable as a process-lifetime per-profile suppression

Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
@d4rken d4rken added the bug Something isn't working label May 3, 2026
@d4rken d4rken merged commit 7d93e77 into main May 3, 2026
11 checks passed
@d4rken d4rken deleted the fix/devicemonitor-persist-crash branch May 3, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant