Investigate Wear OS battery drain — continuous 30s polling and never-stopping sync service #16

Open
opened 2026-05-31 23:51:51 +02:00 by Brummel · 0 comments
Owner

Symptom

A field test of the watch app surfaced noticeably high battery drain. No power profiling (Battery Historian / on-device battery stats) has been captured yet, so the magnitude and the dominant cause are not measured. This issue records the candidate power sinks found by a static code scan so a measurement pass has concrete starting points. App sources live under clients/wearos/app/src/main/java/com/doctate/watch.

Candidate sinks (code verified; contribution to drain not yet measured)

  1. Sync foreground service never stops, polls forever. SyncService is a dataSync foreground service (Doze-exempt), started at app launch and returning START_STICKYclients/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt:77. A grep across sync/ finds no stopSelf/stopForeground/stopService anywhere, so it runs for the whole process lifetime. The worker loop is infinite (while (currentCoroutineContext().isActive), sync/SyncWorkerLoop.kt:115) and at idle waits on a 30 s timeout (DEFAULT_POLL_INTERVAL_MS = 30_000, sync/SyncWorkerLoop.kt:245; waitForKickOrTimeout at :124). Net effect: a server poll roughly every 30 s, indefinitely, even with an empty upload queue and no recent recording. On a watch this round-the-clock radio wake is the strongest candidate.

  2. Post-upload burst polling. After each successful upload the loop self-kicks a 2 s poll for a 60 s window — sync/SyncWorkerLoop.kt:172 (BurstPoll(... nowMs() + BURST_POLL_BUDGET_MS)), constants at :246 (BURST_POLL_INTERVAL_MS = 2_000) and :247 (BURST_POLL_BUDGET_MS = 60_000). That is up to ~30 radio wakeups per case, an intentional trade for sub-five-second oneliner feedback (comment at sync/SyncWorkerLoop.kt:70).

  3. No push path — drain is structural, not incidental. Oneliner readiness is discovered purely by polling; there is no server-push (e.g. FCM) to replace the steady poll. So the 30 s cadence is the design, not a stuck retry.

  4. Transient-error backoff keeps the radio busy. On transient upload errors the loop backs off 2 s→60 s doubling (sync/SyncWorkerLoop.kt:175, constants :243/:244), with OkHttp retryOnConnectionFailure(true) (net/HttpClientProvider.kt:15). Under poor connectivity this keeps retrying rather than yielding.

Ruled out by the same scan (verified clean)

  • No PowerManager/WakeLock usage anywhere.
  • No sensors, location, heart-rate, or Bluetooth scanning.
  • Audio recorder releases promptly in finally (audio/AudioRecorder.kt:48, :59); 16 kHz mono AAC, no on-device transcoding.
  • keepScreenOn is scoped to the recording screen only and cleared in onDispose (presentation/RecordingScreen.kt:75).

Open — needs measurement before any fix

  • Capture a real power profile (Battery Historian / dumpsys batterystats) to confirm which candidate dominates, rather than optimizing blind.
  • Quantify radio wake frequency at idle vs. during/after a recording.

Possible directions (after measurement)

  • Stop SyncService (or drop out of the foreground/loop) when the pending queue is empty and no burst is active, restarting on a new recording or kick.
  • Lengthen or adaptively scale DEFAULT_POLL_INTERVAL_MS; soften the burst (longer interval or shorter budget).
  • Evaluate a server-push (FCM) path to replace steady polling for oneliner readiness.

Note: battery behaviour is not unit-testable in the usual sense; acceptance will rest on a before/after power measurement plus a test pinning any new service-stop/poll-interval logic.

## Symptom A field test of the watch app surfaced noticeably high battery drain. No power profiling (Battery Historian / on-device battery stats) has been captured yet, so the magnitude and the dominant cause are not measured. This issue records the candidate power sinks found by a static code scan so a measurement pass has concrete starting points. App sources live under `clients/wearos/app/src/main/java/com/doctate/watch`. ## Candidate sinks (code verified; contribution to drain not yet measured) 1. **Sync foreground service never stops, polls forever.** `SyncService` is a `dataSync` foreground service (Doze-exempt), started at app launch and returning `START_STICKY` — `clients/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt:77`. A grep across `sync/` finds no `stopSelf`/`stopForeground`/`stopService` anywhere, so it runs for the whole process lifetime. The worker loop is infinite (`while (currentCoroutineContext().isActive)`, `sync/SyncWorkerLoop.kt:115`) and at idle waits on a 30 s timeout (`DEFAULT_POLL_INTERVAL_MS = 30_000`, `sync/SyncWorkerLoop.kt:245`; `waitForKickOrTimeout` at `:124`). Net effect: a server poll roughly every 30 s, indefinitely, even with an empty upload queue and no recent recording. On a watch this round-the-clock radio wake is the strongest candidate. 2. **Post-upload burst polling.** After each successful upload the loop self-kicks a 2 s poll for a 60 s window — `sync/SyncWorkerLoop.kt:172` (`BurstPoll(... nowMs() + BURST_POLL_BUDGET_MS)`), constants at `:246` (`BURST_POLL_INTERVAL_MS = 2_000`) and `:247` (`BURST_POLL_BUDGET_MS = 60_000`). That is up to ~30 radio wakeups per case, an intentional trade for sub-five-second oneliner feedback (comment at `sync/SyncWorkerLoop.kt:70`). 3. **No push path — drain is structural, not incidental.** Oneliner readiness is discovered purely by polling; there is no server-push (e.g. FCM) to replace the steady poll. So the 30 s cadence is the design, not a stuck retry. 4. **Transient-error backoff keeps the radio busy.** On transient upload errors the loop backs off 2 s→60 s doubling (`sync/SyncWorkerLoop.kt:175`, constants `:243`/`:244`), with OkHttp `retryOnConnectionFailure(true)` (`net/HttpClientProvider.kt:15`). Under poor connectivity this keeps retrying rather than yielding. ## Ruled out by the same scan (verified clean) - No `PowerManager`/`WakeLock` usage anywhere. - No sensors, location, heart-rate, or Bluetooth scanning. - Audio recorder releases promptly in `finally` (`audio/AudioRecorder.kt:48`, `:59`); 16 kHz mono AAC, no on-device transcoding. - `keepScreenOn` is scoped to the recording screen only and cleared in `onDispose` (`presentation/RecordingScreen.kt:75`). ## Open — needs measurement before any fix - [ ] Capture a real power profile (Battery Historian / `dumpsys batterystats`) to confirm which candidate dominates, rather than optimizing blind. - [ ] Quantify radio wake frequency at idle vs. during/after a recording. ## Possible directions (after measurement) - [ ] Stop `SyncService` (or drop out of the foreground/loop) when the pending queue is empty and no burst is active, restarting on a new recording or kick. - [ ] Lengthen or adaptively scale `DEFAULT_POLL_INTERVAL_MS`; soften the burst (longer interval or shorter budget). - [ ] Evaluate a server-push (FCM) path to replace steady polling for oneliner readiness. Note: battery behaviour is not unit-testable in the usual sense; acceptance will rest on a before/after power measurement plus a test pinning any new service-stop/poll-interval logic.
Brummel added the bug label 2026-05-31 23:51:51 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Brummel/doctate#16