Compare commits

...
74 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] 3ddd5f3cfd Release: 5.2.1-rc0 2026-07-08 11:21:18 +00:00
Matthias Urhahn 31f4d4aafa fix(battery): Stop remaining-time jumping up when toggling ANC
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.

Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.

The existing spec ceiling and display clamp still backstop the borrowed rate.
2026-07-07 22:23:01 +02:00
Matthias Urhahn e06dc933b8 chore(logging): Log BLE scan reception nanos in scan summaries 2026-07-06 12:42:10 +02:00
Matthias Urhahn 1418be999d feat(reaction): Time-cap auto-pause debounce on slow BLE scanners 2026-07-06 12:42:10 +02:00
d4rken-org-releaser[bot] df06a49949 Release: 5.2.0-rc0 2026-07-03 10:53:28 +00:00
darken b6dcc74383 fix: Hide charging indicator for disconnected devices 2026-07-03 12:43:33 +02:00
darken fead8381bc General: Update app translations from Crowdin 2026-07-03 11:25:05 +02:00
darken 2ab9f8ace6 chore(screenshots): Show battery estimates in dashboard screenshots 2026-07-02 17:32:29 +02:00
darken 81effcb349 chore(screenshots): Regenerate smoke locale screenshots 2026-07-02 17:32:29 +02:00
darken 3dd1f8151a fix(widget): Correct typo in the configuration resize hint 2026-07-02 17:32:29 +02:00
darken 272a7121a9 fix(screenshots): Use resource-qualifier locales for region-coded languages 2026-07-02 17:32:29 +02:00
darken 52a7850c1d feat(battery): Show battery estimates in widget and expanded notification 2026-07-02 17:32:29 +02:00
darken b297ad8564 ui(battery): Simplify the health placeholder text 2026-07-02 17:32:29 +02:00
darken a3810353f7 Revert "feat(battery): Add experimental case charge ETA and case battery health"
This reverts commit 87a9881024.
2026-07-02 17:32:29 +02:00
darken d4dddb1f38 feat(battery): Add experimental case charge ETA and case battery health
- Show the time until the case is full inside its charging chip, learned from
  the case's own rising level with the existing charge-band model; no rating
  exists for case charging, so the first charge learns before it shows
- Derive a case battery health from observed transfer efficiency: pod percent
  gained per case percent spent while docked and unplugged, corrected by each
  pod's own health, compared against Apple's "with charging case" totals
- Case data is only genuine while a pod is docked — both transports silently
  freeze the last value otherwise. Battery updates now flag whether the case
  entry is live, and BLE gains strict same-frame case accessors, so estimates
  never learn from frozen echoes
- The case deliberately gets no runtime estimate: idle-then-burst drain has no
  meaningful hourly rate
- Mark the case metrics as experimental in the Battery settings card
2026-07-02 17:32:29 +02:00
darken 21560a1f3a ui(battery): Tighten estimate setting titles and descriptions 2026-07-02 17:32:29 +02:00
darken bdfb6bb073 ui(battery): Match the divider style of the sibling settings cards 2026-07-02 17:32:29 +02:00
darken aac0590017 ui(battery): Surface health before data exists, reorder the Battery card
- Show a "Still determining — check back after a few more listening sessions"
  placeholder under Battery Health in the device info sheet while the estimate
  is still accumulating data, so the feature is discoverable from day one; no
  placeholder for profiles without a paired device, where it could never resolve
- Reorder the Battery card: time remaining & health settings first, then a
  divider, then the charging-side settings (charge limit, charged notification)
- Mention the battery-health component in the estimate toggle and reset texts
- Ground the charge-band boundaries in Apple's documented fast-to-80%/trickle
  charging behavior and standard lithium CC/CV charging references
2026-07-02 17:32:29 +02:00
darken afba33dd83 feat(battery): Model charge taper per band, base health on listening-only drain
- Replace the single linear charge rate with a three-band model (bulk / taper /
  trickle) matching lithium CC/CV charging: each band learns its own rate, the
  ETA walks the remaining bands, and the spec seed gets a taper haircut for the
  slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
  worn, audio is playing, AND this device is the system's audio sink; idle wear
  previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
  (playback stop, docking, transport flip) instead of being discarded with the
  cleared window
- The time-remaining estimate keeps learning from all usage — actual current
  drain, idle included, is the right basis for "how long will they last"
2026-07-02 17:32:29 +02:00
darken 5de5dee52f ui(battery): Show charge ETA in the charging chip, make health per-pod
- The gauge line under the percentage now always shows the runtime estimate
  ("if used now") even while charging; the time-until-charged moved into the
  charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
  the info sheet, mirroring the serial rows) — single-pod listening habits or
  a replaced earbud make the sides genuinely diverge, and a combined figure
  would mask a failing pod
2026-07-02 17:32:29 +02:00
darken ba3e253569 fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging
- While charging, the gauge showed the bare runtime projection when no charge
  rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
  charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
  in the case provides around 1 hour of listening"), normalized against the
  rated listening hours, so the ETA is present from the very first charge; a
  live fit still takes over within minutes
2026-07-02 17:32:29 +02:00
darken 4488af0ab7 feat(battery): Add time-until-charged and derived battery health
- While a pod charges, fit its rising level and show the time until full in the
  gauge instead of the runtime estimate; learned charge rates are persisted per
  slot so the ETA appears immediately on later charges
- Suppress the charge ETA during Optimized Battery Charging holds, the final
  trickle phase, and whenever the level stalls longer than one visible step
  should take (granularity-aware: 1% AAP steps vs 10% BLE steps)
- Clear a slot's fit window when its readings switch between AAP and BLE — the
  granularity jump would otherwise read as a fake level step
- Derive a battery-health percentage (median of accumulated drain rates vs the
  model's rated life) and show it in the device info sheet; the info button now
  also appears for BLE-only devices once health data exists
- Tag learned rates with the model they came from so re-pointing a profile at
  different hardware starts learning fresh instead of inheriting foreign rates
- Track how many sessions blended into each learned rate and require three
  before a health figure is shown
2026-07-02 17:32:29 +02:00
darken aae8ad62bd feat(battery): Make estimate per-device and seed from model specs
- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
  the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
  Battery card; the charge notification now works for any live device, not only classic
  audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
  drain stops over-promising within a couple of updates
2026-07-02 17:32:29 +02:00
darken 9606a33f15 feat: Add earbud battery time-remaining estimate 2026-07-01 17:58:12 +02:00
darken f626d70538 fix(monitor): Show worn device in notification and popup 2026-06-30 23:29:34 +02:00
d4rken-org-releaser[bot] a91ef44686 Release: 5.1.10-rc0 2026-06-22 19:00:07 +00:00
darken e3bef4e74b General: Update app translations from Crowdin 2026-06-22 20:42:35 +02:00
darken 358bf5e244 fix(monitor): Drive monitor mode from first addressed profile 2026-06-22 20:16:37 +02:00
d4rken-org-releaser[bot] fba2d3a59d Release: 5.1.9-rc0 2026-06-18 12:00:29 +00:00
darken 0abba4ece2 test(reaction): Harden Conversational Awareness pod-removal coverage 2026-06-18 13:57:07 +02:00
darken e9b0694b67 fix(reaction): Honor One-Pod Mode in CA resume worn-guard 2026-06-18 12:07:19 +02:00
darken 49bbf1bc85 fix(reaction): Settle cold CA terminals to catch pod-removal re-key race 2026-06-18 12:07:19 +02:00
darken f5b6a5c605 fix(reaction): Ignore CA terminals from pod removal/insertion re-key 2026-06-18 12:07:19 +02:00
darken fccc6cedd5 fix(reaction): Restore CA status 6 as a terminal 2026-06-18 12:07:19 +02:00
darken a03766448e fix(reaction): Apply resume age-guard only to the inferred stale-end 2026-06-18 12:07:19 +02:00
darken f0c9d318ff fix(aap): Report speaking=true for Conversational Awareness resume (status 5) 2026-06-18 12:07:19 +02:00
darken 5c5cc6f28e fix(reaction): Refresh CA keep-alive timestamp so long conversations resume 2026-06-18 12:07:19 +02:00
darken 246b96bfb4 fix(reaction): Treat CA status 5 as speech-resume, not a stop 2026-06-18 12:07:19 +02:00
darken 352e020b54 test(aap): Make handshake-timeout test deterministic with real-time bounds 2026-06-15 09:16:51 +02:00
darken 72f5690ea6 test(aap): Stabilize flaky handshake-timeout test under CI load 2026-06-15 09:16:51 +02:00
darken 1ed1199574 fix(ble): Stop foreign same-model pods from hijacking a device's identity 2026-06-15 09:16:51 +02:00
darken fd4a43de96 fix(aap): Bound handshake with watchdog and escalate reconnect backoff 2026-06-14 21:28:42 +02:00
darken e406fd4cc1 fix(monitor): Restore tint on product icons to prevent invisibility on light backgrounds
Without tint, white PNG product icons (AirPods photos) are invisible on
light notification backgrounds. Restore src_in tint with notification_icon_tint
(a reliable day/night color resource) in both the PodInfoItemIcon.Notification
style (covers small layouts) and the big layout product ImageViews that do
not use that style.
2026-06-14 17:52:45 +02:00
darken 1e555cc712 fix(monitor): Fix notification color inconsistency in dark mode on One UI
Remove Theme.Material3.DynamicColors.DayNight from the notification
container style; it does not resolve correctly in the notification host
process, causing light-mode colors to appear in dark mode. Replace all
?attr/colorAccent and ?android:attr/textColorPrimary refs in RemoteViews
layouts with explicit day/night color resources (notification_icon_tint)
and TextAppearance.Compat.Notification.Title defaults. Change tintMode
from multiply to src_in for status glyphs; remove tint from product PNG
icons to prevent silhouette rendering.

Fixes #613
2026-06-14 17:52:45 +02:00
d4rken-org-releaser[bot] 227a6b2a7b Release: 5.1.8-rc0 2026-06-10 18:25:44 +00:00
darken 5f661d71a6 General: Update app translations from Crowdin 2026-06-10 20:20:37 +02:00
darken 2804543b9d feat(reaction): Add charged notification 2026-06-10 18:13:51 +02:00
darken 380397321c fix(reaction): Apply volume reduction slider only on drag release
The conversation volume slider committed a profile write on every
drag frame — laggy dragging, and racing async writes persisted stale
mid-drag values (observed: 78 stored while the UI showed 70). Use
local state while dragging and commit on release, matching the
adaptive-noise and tone-volume sliders.
2026-06-10 11:39:58 +02:00
darken d9c8902d34 fix(reaction): Restore volume when conversation end frame never arrives
With one AirPod in the case the pod deterministically drops the
terminal 0x4B frame: the wind-down flurry ends on a transitional
status (1,2,3,0xB,4 then nothing), so the reaction never disengaged
and the volume stayed low until the 5-minute backstop (#608).
Reproduced on Pro 3 (fw 6589, 6503) and Pro 2 USB-C (fw 6814) —
all three share one firmware train; with both pods worn the terminal
always arrives.

The pod sends no frames during active speech, so any non-START frame
means the wind-down has begun and a terminal is imminent. A HOLD now
arms a short 6s fuse instead of the 5-minute backstop: if no terminal
(or fresh START) follows, the reaction disengages anyway. The fuse
must stay above ~5s — gaps up to 2.8s were observed between
consecutive wind-down frames. STARTs still arm the long backstop,
since the pod stays engaged and silent against ambient noise for
20-30s+ and a short timeout there resumes media mid-conversation.

Validated on hardware on both models: 7/7 single-pod conversations
restored exactly 6s after the last wind-down frame.
2026-06-10 11:39:58 +02:00
darken a1b9abd4cb fix(ble): Drop proximity messages with unsupported payload prefix
AirPods Gen4 emit a one-off type 0x07 frame from their identity address
on connect whose payload prefix is 0x07 instead of 0x01. Its bytes are
not the plaintext status format, but the model bytes happen to match,
so it minted a fresh device tracker and showed as a duplicate device
card until the stale timeout.

Every known plaintext status broadcast (including pairing mode) uses
prefix 0x01; pairing state lives in the suffix byte. Reject anything
else at the decoder, logging the dropped frame's hex.

Fixes #603
2026-06-10 10:44:48 +02:00
d4rken-org-releaser[bot] 5d30844fa8 Release: 5.1.7-rc0 2026-06-08 07:22:52 +00:00
Matthias Urhahn af1b1b4249 Revert #605's cooldown-only mitigation in favor of the root-cause fix
@mvanhorn independently fixed #598 in #605 by no longer resetting the
case cooldown on close, throttling the re-pop. We cherry-picked that
commit above to keep his authorship/credit, but this PR instead removes
the underlying lid-state flapping at its source (out-of-case pod's stale
lid byte -> UNKNOWN), so the cooldown can keep resetting on close and a
genuine close->reopen still shows the popup. Reverting his change here so
the two approaches don't stack; thanks @mvanhorn for the parallel work.
2026-06-08 08:05:50 +02:00
Matt Van Horn 2cce9ae56c fix: case-open popup re-appears after the case is closed 2026-06-08 08:05:50 +02:00
Matthias Urhahn 4bab7c61a9 fix(reaction): Stop case popup flicker when one AirPod is out of case
When one pod is out of the case, the out-of-case pod broadcasts bit4-only
frames whose lid byte is stale and decodes to a phantom OPEN even while the
case is shut, interleaved ~1:1 with the correct in-case-pod (bit6) frames.
The derived lid state flapped OPEN<->CLOSED, so the case-open popup re-popped
~0.5s after closing and sometimes lingered. Verified on AirPods Pro 1 (issue
log) and Pro 3 (live BLE capture).

- Trust the lid bit only from in-case-pod (bit6) or both-in-case (bit2) frames;
  bit4-only frames now decode to UNKNOWN, and the real state is recovered from a
  recent in-case broadcast (matches LibrePods).
- getLatestCaseLidState recovers from history within a 2s age window instead of a
  fixed frame count, so a missed CLOSED can't keep a stale OPEN.
- Don't refresh the show-cooldown on a non-CLOSED hide, so a transient UNKNOWN
  can't suppress a genuine re-open.
- Add a freshness backstop: dismiss the popup if a fresh OPEN broadcast stops
  arriving (device left BLE range while open).
- Key popup/auto-connect de-duplication on the derived lid state, not just raw
  advertisement bytes, since the effective lid can change while bytes don't.

Closes #598
2026-06-08 08:05:50 +02:00
Matthias Urhahn 81e2bc1dba feat(overview): Suggest troubleshooter when a connected device sends no data
Adds a dashboard hint card pointing at the existing Troubleshooter when a profile is connected to the system (audio) but CAPod is receiving no live data — the symptom of a phone dropping AirPods BLE broadcasts (e.g. some HyperOS devices, #603). Debounced ~15s so it doesn't flash during the gap between an audio connection and the first broadcast, and suppressed whenever any pod is live so it never claims 'no data' while data is visibly arriving.
2026-06-08 07:45:29 +02:00
Matthias Urhahn df2783d8a1 feat(troubleshooter): Persist only the minimal working compat combo
Probe compatibility options through a transient in-memory override on BlePodMonitor instead of writing the user's persisted settings on every attempt. Only the winning combo is persisted, and only on success; failure or cancellation clears the override, restoring the user's original settings. Combos are tried fewest-disables-first so a phone that only needs batching disabled isn't left with filtering disabled too. Per-attempt cache clearing plus a freshness cutoff stop a previous combo's cached devices from satisfying the next one, and the 'found' checks now require a fresh live BLE observation rather than cached/AAP state.
2026-06-08 07:45:29 +02:00
Matthias Urhahn 20a6953b54 General: Update app translations from Crowdin 2026-06-07 13:30:43 +02:00
darken fdd4f69247 feat(overview): Add setting to hide unmatched devices 2026-05-31 10:04:26 +02:00
darken 5ee777b196 fix(reaction): Don't resume media mid-talk on Conversational Awareness
The pod only signals CA start and end, not continuous keep-alives. On fw 6861 it held CA engaged for 21s with zero 0x4B frames while the wearer kept talking, so the 12s stale-timeout fired mid-speech and resumed media; the pod never re-sent a start frame, so it didn't re-pause.

Disengage now waits for the explicit not-speaking frame (status 5 added as a terminal STOP, since that firmware winds down 3->5 and never reaches 6/8/9). Transitional/unknown statuses stay engaged. The stale timer is demoted to a long 5min backstop for a fully-dropped terminal frame; constants moved to Kotlin Durations.
2026-05-31 06:39:52 +02:00
d4rken-org-releaser[bot] dd827af3cd Release: 5.1.6-rc0 2026-05-26 13:32:39 +00:00
Matthias Urhahn 948205667b General: Update translations for 75 languages (#600)
* General: Update app translations from Crowdin

* General: Include latest Polish community translation updates
2026-05-26 12:20:17 +02:00
darken dbbfa75429 feat(reaction): Add Conversational Awareness media reaction
Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off).

Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup.
2026-05-25 23:55:55 +02:00
Matthias Urhahn 78790f73df Delete Screenshot_20220914_174644.png~ 2026-05-11 12:39:16 +02:00
darken 2950d8b3f9 docs: Update README screenshots to current fastlane set 2026-05-11 12:37:41 +02:00
Matthias Urhahn 4d3a9bc165 refactor: Remove General Settings Debug menu and dead supporting code 2026-05-07 15:41:57 +02:00
Matthias Urhahn 7d57845502 fix(monitor): Make Extra notification toggle reactive 2026-05-07 15:23:06 +02:00
Matthias Urhahn 83ef111599 refactor: Move nudge persistence into GeneralSettings
Consolidate the nudge availability DataStoreValue with the rest of the persisted settings instead of carrying it in a separate Hilt module + DataStore file. Matches the existing convention where compat-style flags (offloaded filtering, indirect callbacks, etc.) live alongside theme/notification settings.

NudgeCapabilityStore keeps the verdict-mapping behavior; only the source of the persisted value changes.
2026-05-07 14:10:16 +02:00
Matthias Urhahn 0e6848d886 fix: Address Codex review on auto-monitor-mode
- Seed connectedDevices flow with onStart so ALWAYS autolaunch fires before HEADSET profile binds

- Gate UnavailableMissingPermission classification on Android 12+ (BLUETOOTH_CONNECT only exists from API 31)

- Treat blank/empty profile.address as unpaired (matches AutoConnect)

- Subscribe DeviceSettingsViewModel to nudgeCapabilityStore.availability so UI updates immediately on verdict change

- Seed availability StateFlow with the persisted value via valueBlocking to close the cold-start race
2026-05-07 14:10:16 +02:00
Matthias Urhahn 1eeeb63a76 test: Cover nudge persistence, permission gating, and autolaunch reactivity 2026-05-07 14:10:16 +02:00
Matthias Urhahn a4ba302223 refactor(monitor): Compute monitor mode automatically from profile state 2026-05-07 14:10:16 +02:00
Matthias Urhahn 8c9094d85a ui(reaction): Tighten Auto play / Start music on wear descriptions 2026-05-07 14:08:22 +02:00
Matthias Urhahn 4529b107dd fix(reaction): Only auto-resume after ear-removal pause, not stem or sleep
Matches Apple's iOS/macOS behavior: stem-press pauses and sleep-detection pauses are explicit user intent, not eligible for auto-resume on next pod-in. sendPause gains a rememberForResume parameter (default false); only PlayPause's auto-pause branch passes true. New sendStop wrapper clears the auto-resume flag for stem-mapped MEDIA_STOP.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 17663bc759 refactor(reaction): Switch to sticky resume flag with opt-in cold-wear
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 278257998b fix(reaction): Don't auto-resume music after a user-initiated pause 2026-05-07 14:08:22 +02:00
250 changed files with 14689 additions and 2824 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

+1 -2
View File
@@ -81,8 +81,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
## Thanks to
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -1 +1 @@
5.1.5-rc0 50105000
5.2.1-rc0 50201000
@@ -21,11 +21,14 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
@@ -52,12 +55,35 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
MockPodDataProvider.unknownMonitored(),
)
}
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
// the charging chip of whichever mock pod is charging.
val estimates = if (showAap) {
mapOf(
"preview-dual-aap" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single-aap" to BatteryEstimate(
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
),
)
} else {
mapOf(
"preview-dual-mixed" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
),
)
}
OverviewScreen(
state = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = devices,
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -65,6 +91,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -229,7 +256,7 @@ internal fun HomescreenWidgetContent() {
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
),
).withLocalizedPreviewEstimates(LocalContext.current),
)
}
}
+7 -7
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
</resources>
+2 -2
View File
@@ -5,7 +5,7 @@
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez sekšanas. Bez Google Play saistībām.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Jūsu atbalsts uztur CAPod pie dzīvības.</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+8 -8
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
<string name="upgrade_badge_label">احترافي</string>
<string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
<string name="upgrade_badge_label">النسخة الاحترافية</string>
</resources>
+3 -3
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmantojat pareizo kontu?</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmanto pareizo kontu?</string>
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūdzu, mēģiniet vēlreiz vēlāk vai pārstartējiet tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūgums mēģināt vēlreiz vēlāk vai pārsāknēt tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play jūsu pirkuma informāciju. Notīriet Google Play kešatmiņu un pārstartējiet tālruni.\n\nKļūda %s</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play pirkuma informāciju. Jāiztīra Google Play kešatmiņa un pārsāknē tālrunis.\n\nKļūda %s</string>
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
<string name="upgrade_badge_label">Pro</string>
</resources>
+14 -2
View File
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -36,6 +40,7 @@ open class App : Application() {
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -62,8 +67,15 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles()
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
combine(
deviceMonitor.devicesWithProfiles(),
batteryEstimator.estimates,
) { devices, estimates -> devices to estimates }
// Estimate display values can change while the device key is stable (e.g. a charge
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
.distinctUntilChangedBy { (devices, estimates) ->
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
}
.throttleLatest(1000)
.onEach {
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
@@ -1,8 +1,11 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
private val audioManager: AudioManager,
private val timeSource: TimeSource,
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
/**
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
* dispatches a resume or when music transitions inactive→active from any source. Read by
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
*
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
* pod-out conversations and had a known race where music starting again via another source
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
*/
@Volatile private var capPaused: Boolean = false
@Volatile private var lastKnownMusicActive: Boolean = false
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
val nowActive = audioManager.isMusicActive
if (!lastKnownMusicActive && nowActive) {
// Music started by some source (could be us via sendPlay or someone else).
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
// re-fire a play key over already-active music.
capPaused = false
}
lastKnownMusicActive = nowActive
}
}
init {
// Seed the active flag from current state so we won't miss the next inactive→active
// transition if music is already playing when MediaControl is constructed.
lastKnownMusicActive = audioManager.isMusicActive
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
}
val isPlaying: Boolean
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
get() = capPaused
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
if (audioManager.isMusicActive && !capPaused) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause()
capPaused = false
}
/**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* Returns `true` when a key event was actually dispatched (and the 15-second
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
* cooldown on a real pause — should branch on the return value rather than checking
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
*
* Returns `true` when a key event was actually dispatched, `false` when the call was a
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
* a pending auto-resume from a recent ear-removal pause). Callers that need to
* distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
* reaction, which gates its notification and cooldown on a real pause — should branch
* on the return value rather than checking [isPlaying] themselves to avoid a
* check-then-act race with the audio system.
*/
suspend fun sendPause(): Boolean {
log(TAG, INFO) { "sendPause()" }
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return false
}
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
// playback callback that fires during the dispatch (e.g. a fast user resume on the
// phone, or another app grabbing audio focus and immediately starting) could clear
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
// This single explicit assignment also covers the contract that an explicit user
// pause cancels a pending auto-resume.
capPaused = rememberForResume
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
}
/**
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
*/
suspend fun sendStop() {
log(TAG, INFO) { "sendStop()" }
capPaused = false
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) {
if (capPaused) {
sendPlay()
return
}
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
)
}
private fun markRecentCapPause() {
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
/**
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
* the prior + the volume actually applied, so the caller can later restore it and detect whether
* the user changed the volume in the meantime.
*
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
* frequent push event and the volume panel flashing would be noisy. The applied target is read
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
*/
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
return null
}
if (audioManager.isVolumeFixed) {
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
return null
}
val percent = reductionPercent.coerceIn(0, 100)
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
} else {
0
}
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
if (target >= prior) {
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
return null
}
return try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
VolumeDuck(priorVolume = prior, appliedVolume = applied)
} catch (e: SecurityException) {
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
null
}
}
private fun clearRecentCapPause() {
capPauseExpiryElapsedRealtime = 0L
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
fun restoreMusicVolume(priorVolume: Int) {
if (audioManager.isVolumeFixed) return
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
} catch (e: SecurityException) {
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
}
}
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
data class VolumeDuck(
val priorVolume: Int,
val appliedVolume: Int,
)
companion object {
private val TAG = logTag("MediaControl")
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
}
}
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
.sortedBy { it.key }
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
.ifEmpty { "-" }
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
}
@JvmName("logBleScanResultCollectionSummary")
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
.sorted()
.joinToString(separator = ",")
.ifEmpty { "-" }
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
}
@JvmName("logFrameworkScanResultCollectionSummary")
@@ -33,7 +33,6 @@ import javax.inject.Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
private val timeSource: TimeSource,
) {
@@ -206,7 +205,6 @@ class BleScanner @Inject constructor(
log(TAG) { "BleScanner stopped" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -23,6 +24,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
@@ -390,9 +392,6 @@ class BluetoothManager2 @Inject constructor(
initialValue = emptySet(),
)
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
/**
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
@@ -425,29 +424,42 @@ class BluetoothManager2 @Inject constructor(
}
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val target = device.internal ?: return@map false
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (isSecurityException) {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
_isNudgeAvailable = false
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (!isSecurityException) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
return@map NudgeAttemptResult.Rejected
}
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
// versions a SecurityException can't be a missing-permission failure for that
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
// should persist BROKEN.
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
!Permission.BLUETOOTH_CONNECT.isGranted(context)
if (missingBtConnect) {
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
NudgeAttemptResult.UnavailableMissingPermission
} else {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
NudgeAttemptResult.UnavailableHiddenApi
}
}
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
@@ -1,103 +0,0 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import javax.inject.Inject
import kotlin.random.Random
import eu.darken.capod.common.datastore.valueBlocking
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
) {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.valueBlocking) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
val now = timeSource.now()
val generatedAt = timeSource.elapsedRealtimeNanos()
// AirPods Gen1
BleScanResult(
receivedAt = now,
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen2
BleScanResult(
receivedAt = now,
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen3
BleScanResult(
receivedAt = now,
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Max
BleScanResult(
receivedAt = now,
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
// BeatsFlex
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
// Tws i99999
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
// Unknown Device
BleScanResult(
receivedAt = now,
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.common.bluetooth
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class NudgeAvailability {
@SerialName("nudge.unknown") UNKNOWN,
@SerialName("nudge.available") AVAILABLE,
@SerialName("nudge.broken") BROKEN,
}
enum class NudgeAttemptResult {
Accepted,
Rejected,
UnavailableHiddenApi,
UnavailableMissingPermission,
}
@@ -0,0 +1,59 @@
package eu.darken.capod.common.bluetooth
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NudgeCapabilityStore @Inject constructor(
generalSettings: GeneralSettings,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
) {
private val persistedValue = generalSettings.nudgeAvailability
// Resolve the persisted value synchronously at construction so consumers that read
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.Eagerly,
initialValue = persistedValue.valueBlocking,
)
fun record(result: NudgeAttemptResult) {
val verdict = verdictFor(result) ?: return
if (availability.value == verdict) return
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
appScope.launch(dispatcherProvider.IO) {
persistedValue.value(verdict)
}
}
companion object {
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
NudgeAttemptResult.Rejected,
NudgeAttemptResult.UnavailableMissingPermission -> null
}
}
}
@@ -212,6 +212,37 @@ object MockPodDataProvider {
),
)
/**
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
*/
fun dualPodFullyLoaded(): PodDevice = PodDevice(
profileId = "preview-dual-full",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
pod = AapSetting.PrimaryPod.Pod.LEFT,
),
),
),
)
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
profileId = "preview-dual-reactions",
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
) {
val keyName: String get() = key.name
val flow: Flow<T> = dataStore.data.map { prefs ->
reader(prefs[key])
}
val flow: Flow<T> = dataStore.data
.map { prefs -> prefs[key] }
.distinctUntilChanged()
.map { raw -> reader(raw) }
data class Updated<T>(val old: T, val new: T)
@@ -8,9 +8,19 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.asLogSummary
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.MutableStateFlow
object Bugs {
var reporter: AutomaticBugReporter? = null
/**
* True while the debug log recorder is actively recording. Surfaces extra
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
* to add support for new headphones can capture useful data without a
* permanent debug toggle.
*/
val isDebug = MutableStateFlow(false)
fun report(
tag: String,
message: String,
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
}
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
}
}
.launchIn(appScope)
internalState.flow
.onEach { Bugs.isDebug.value = it.isRecording }
.launchIn(appScope)
}
private fun createSessionDir(): File {
@@ -36,9 +36,6 @@ object Nav {
@Serializable
data object General : Settings
@Serializable
data object Debug : Settings
@Serializable
data object Support : Settings
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod
@@ -36,8 +37,6 @@ class GeneralSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
val keepConnectedNotificationAfterDisconnect =
@@ -74,10 +73,18 @@ class GeneralSettings @Inject constructor(
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
val nudgeAvailability = dataStore.createValue(
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
onErrorFallbackToDefault = true,
)
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -1,21 +1,13 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class MonitorMode(
@StringRes val labelRes: Int
val intensity: Int,
) {
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
}
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
@@ -21,7 +22,7 @@ import javax.inject.Singleton
@Singleton
class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val monitorModeResolver: MonitorModeResolver,
private val profilesRepo: DeviceProfilesRepo,
) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger,
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
anyPopupEnabled,
) { _, monitorMode, showPopUp ->
Permission.entries
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
.filter { isApplicable(it, monitorMode, showPopUp) }
.filter { it.isRequired(context) }
.toSet()
}
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
companion object {
private val TAG = logTag("PermissionTool")
/**
* Whether [permission] is applicable for the user's current configuration.
* Three permissions are conditional on monitor mode or popup usage:
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
* Everything else is unconditionally applicable.
*/
internal fun isApplicable(
permission: Permission,
monitorMode: MonitorMode,
anyPopupEnabled: Boolean,
): Boolean = when (permission) {
Permission.IGNORE_BATTERY_OPTIMIZATION,
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
else -> true
}
}
}
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
@@ -64,6 +65,8 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -148,6 +151,8 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onConversationActionChange = { vm.setConversationAction(it) },
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
@@ -167,13 +172,18 @@ fun DeviceSettingsScreenHost(
onOnePodModeChange = { vm.setOnePodMode(it) },
onAutoPlayChange = { vm.setAutoPlay(it) },
onAutoPauseChange = { vm.setAutoPause(it) },
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
onAutoConnectChange = { vm.setAutoConnect(it) },
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
onChargedThresholdChange = { vm.setChargedThreshold(it) },
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
)
}
@@ -186,6 +196,8 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
@@ -205,13 +217,18 @@ fun DeviceSettingsScreen(
onOnePodModeChange: (Boolean) -> Unit = {},
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
onResetBatteryEstimate: () -> Unit = {},
) {
val device = state.device
val features = device?.model?.features
@@ -279,6 +296,23 @@ fun DeviceSettingsScreen(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
@@ -339,22 +373,42 @@ fun DeviceSettingsScreen(
device = device,
features = features,
isPro = isPro,
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange,
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onConversationActionChange = onConversationActionChange,
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onFixMonitorMode = onFixMonitorMode,
onOpenIssueTracker = onOpenIssueTracker,
)
}
}
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
if (device != null && features != null && device.isLive) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
isPro = isPro,
chargeCapControlEnabled = enabled,
estimateEnabled = state.batteryEstimateEnabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
onChargedThresholdChange = onChargedThresholdChange,
onChargedSlotScopeChange = onChargedSlotScopeChange,
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
onResetEstimate = onResetBatteryEstimate,
)
}
}
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
@@ -423,18 +477,6 @@ fun DeviceSettingsScreen(
}
}
// ── Battery ──────────────────────────────────
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
enabled = enabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
)
}
}
// ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -16,10 +18,14 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.BatteryHealth
import eu.darken.capod.monitor.core.battery.DrainProfile
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -29,6 +35,8 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,7 +59,10 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val batteryEstimator: BatteryEstimator,
private val drainStore: BatteryDrainStore,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
@@ -114,8 +125,10 @@ class DeviceSettingsViewModel @Inject constructor(
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
// Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
nudgeCapabilityStore.availability,
drainStore.profiles,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info
@@ -127,9 +140,13 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
val nudgeAvailability = args[7] as NudgeAvailability
@Suppress("UNCHECKED_CAST")
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
?.stemActions
val stemActions = appleProfile?.stemActions
val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr ->
try {
@@ -142,7 +159,7 @@ class DeviceSettingsViewModel @Inject constructor(
device = device,
now = timeSource.now(),
isPro = upgrade.isPro,
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing,
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode,
@@ -151,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true,
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
// Health rides on the same learned data as the estimate — the per-device toggle
// governs both.
batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
// A model with a rating WILL eventually produce a health figure — surface the
// feature as "still determining" until the listening sessions accumulate. Without
// a paired device the listening gate can never open, so no promise is made.
batteryHealthPending = device != null &&
(appleProfile?.batteryEstimateEnabled ?: true) &&
device.model.batterySpec != null &&
device.hasSelectedPairedDevice,
)
}
}.asLiveState()
@@ -176,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
val batteryHealth: BatteryHealth.PerPod? = null,
/** True when health CAN be derived for this device (rated model, feature on) — shows the
* "still determining" placeholder while [batteryHealth] is null. */
val batteryHealthPending: Boolean = false,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -206,18 +242,19 @@ class DeviceSettingsViewModel @Inject constructor(
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
if (!bluetoothManager.isNudgeAvailable) {
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
val accepted = try {
val result = try {
bluetoothManager.nudgeConnection(bonded)
} catch (e: Exception) {
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
false
NudgeAttemptResult.Rejected
}
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
if (!accepted) {
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
nudgeCapabilityStore.record(result)
if (result != NudgeAttemptResult.Accepted) {
events.tryEmit(Event.OpenBluetoothSettings)
}
} finally {
@@ -382,6 +419,15 @@ class DeviceSettingsViewModel @Inject constructor(
syncEarDetection(autoPause = enabled)
}
fun setStartMusicOnWear(enabled: Boolean) = launch {
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(startMusicOnWear = enabled) }
}
/**
* Keeps the device-side Automatic Ear Detection setting in sync with the
* auto-play / auto-pause reaction toggles. When either reaction is active
@@ -405,15 +451,6 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoConnect(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoConnect($enabled)" }
updateProfileNow { it.copy(autoConnect = enabled) }
if (enabled) {
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
// nothing is connected. We intentionally do NOT revert on disable — the user
// may have set ALWAYS manually, or another profile may still need it.
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
}
}
}
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
@@ -431,11 +468,54 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
fun setMonitorModeAutomatic() = launch {
log(TAG, INFO) { "setMonitorModeAutomatic()" }
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
fun setNotifyWhenCharged(enabled: Boolean) {
log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
proGatedReaction(enabled) { it.copy(notifyWhenCharged = enabled) }
}
fun setChargedSlotScope(scope: ChargedSlotScope) = launch {
log(TAG, INFO) { "setChargedSlotScope($scope)" }
updateProfileNow { it.copy(chargedSlotScope = scope) }
}
fun setChargedThreshold(percent: Int) = launch {
log(TAG, INFO) { "setChargedThreshold($percent)" }
val snapped = (percent.toFloat() / ReactionConfig.CHARGED_THRESHOLD_STEP)
.let { Math.round(it) * ReactionConfig.CHARGED_THRESHOLD_STEP }
.coerceIn(ReactionConfig.MIN_CHARGED_THRESHOLD, ReactionConfig.MAX_CHARGED_THRESHOLD)
updateProfileNow { it.copy(chargedThreshold = snapped) }
}
fun setConversationAction(action: ConversationAction) = launch {
log(TAG, INFO) { "setConversationAction($action)" }
// The action picker is only shown while Conversation Awareness is already enabled (the pod
// emits no speaking frames otherwise), so no need to auto-enable it here.
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(conversationAction = action) }
}
fun setConversationVolumeReduction(percent: Int) = launch {
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
}
// ── Battery estimate (per-profile) ───────────────────────────────────────
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
}
fun resetBatteryEstimate() = launch {
val profileId = targetProfileId.value ?: return@launch
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
batteryEstimator.reset(profileId)
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch
@@ -2,41 +2,176 @@ package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.NotificationsActive
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
/**
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
* used by every other boolean setting.
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
* battery health lead, then a divider, then the charging-side settings:
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
* it starts over from the model's rated battery life.
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
* works for any live device (BLE or AAP), not just when the phone is the audio source.
*
* Each row is gated independently; the whole card hides when nothing applies.
*/
@Composable
internal fun BatteryCard(
device: PodDevice,
features: PodModel.Features,
enabled: Boolean,
isPro: Boolean,
chargeCapControlEnabled: Boolean,
estimateEnabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onEstimateEnabledChange: (Boolean) -> Unit = {},
onResetEstimate: () -> Unit = {},
) {
if (!features.hasDynamicEndOfCharge) return
val cap = device.dynamicEndOfCharge ?: return
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
val showLiveControls = device.isLive && device.profileId != null
if (chargeCap == null && !showLiveControls) return
val reactions = device.reactions
var showResetConfirm by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = cap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = enabled,
// Time remaining & battery health lead — the headline feature. Charging-side settings
// (Apple's charge limit, the charged notification) follow below the divider.
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.Schedule,
title = stringResource(R.string.device_battery_estimate_toggle_label),
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
checked = estimateEnabled,
onCheckedChange = onEstimateEnabledChange,
)
SettingsBaseItem(
icon = Icons.TwoTone.RestartAlt,
title = stringResource(R.string.device_battery_estimate_reset_action),
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
onClick = { showResetConfirm = true },
)
// Same divider style the sibling device-settings cards use (see ReactionsCard).
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (chargeCap != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = chargeCap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = chargeCapControlEnabled,
)
}
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.NotificationsActive,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
}
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showResetConfirm) {
AlertDialog(
onDismissRequest = { showResetConfirm = false },
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
showResetConfirm = false
onResetEstimate()
},
) {
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
}
},
dismissButton = {
TextButton(onClick = { showResetConfirm = false }) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
}
@@ -48,8 +183,10 @@ private fun BatteryCardEnabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = true,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = true,
chargeCapControlEnabled = true,
estimateEnabled = true,
)
}
@@ -60,7 +197,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = false,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = false,
chargeCapControlEnabled = false,
estimateEnabled = false,
)
}
@@ -18,6 +18,9 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
)
internal data class DeviceInfoDetailLabels(
@@ -31,14 +34,52 @@ internal data class DeviceInfoDetailLabels(
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
val batteryHealth: String,
val leftBatteryHealth: String,
val rightBatteryHealth: String,
)
/**
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
* stays discoverable while the estimator is still accumulating listening sessions.
*/
internal data class BatteryHealthTexts(
val left: String? = null,
val right: String? = null,
val headset: String? = null,
val pending: String? = null,
)
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
if (health == null) return emptyList()
return buildList {
when {
health.left != null && health.right != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
)
)
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
}
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
if (isEmpty() && health.pending != null) {
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
}
}
}
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
batteryHealth: BatteryHealthTexts? = null,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
if (info == null) return emptyList()
// Battery health is derived locally, so it's available (and shows the info button) even for
// BLE-only devices that never produce an AAP device-info response.
if (info == null) return healthItems(batteryHealth, labels)
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
@@ -82,5 +123,6 @@ internal fun buildDeviceInfoDetailItems(
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
}
addAll(healthItems(batteryHealth, labels))
}
}
@@ -4,12 +4,14 @@ import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.RecordVoiceOver
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -18,6 +20,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -29,39 +32,45 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ReactionsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -73,6 +82,16 @@ internal fun ReactionsCard(
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
if (reactions.autoPlay) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_start_music_on_wear_label),
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
checked = reactions.startMusicOnWear,
onCheckedChange = onStartMusicOnWearChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
@@ -117,20 +136,49 @@ internal fun ReactionsCard(
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
if (showConversationAwareness) {
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
checked = convAwareness?.enabled == true,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
// speaking frames otherwise), so only surface it once CA is actually enabled.
if (convAwareness?.enabled == true) {
SettingsBaseItem(
icon = Icons.TwoTone.RecordVoiceOver,
title = stringResource(R.string.settings_conversation_action_label),
subtitle = stringResource(reactions.conversationAction.labelRes),
onClick = { showConversationActionDialog = true },
enabled = enabled,
requiresUpgrade = !isPro,
)
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
var sliderValue by remember(reactions.conversationVolumeReduction) {
mutableIntStateOf(reactions.conversationVolumeReduction)
}
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
title = stringResource(R.string.settings_conversation_volume_reduction_label),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onConversationVolumeReductionChange(sliderValue) },
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
}
}
if (features.hasSleepDetection) {
if (showConversationAwareness) ReactionsDivider()
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
@@ -198,23 +246,24 @@ internal fun ReactionsCard(
requiresUpgrade = !isPro,
)
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_warning_manual_mode),
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = onFixMonitorMode) {
Text(stringResource(R.string.general_fix_it_action))
}
},
)
} else if (anyPopupEnabled) {
if (anyPopupEnabled) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_info_not_in_app),
)
}
}
if (showConversationActionDialog) {
ConversationActionDialog(
current = reactions.conversationAction,
onSelect = {
onConversationActionChange(it)
showConversationActionDialog = false
},
onDismiss = { showConversationActionDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -246,7 +295,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -259,6 +307,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -0,0 +1,75 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
@Composable
internal fun ChargedSlotScopeDialog(
current: ChargedSlotScope,
onSelect: (ChargedSlotScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_charged_scope_label)) },
text = {
Column(Modifier.selectableGroup()) {
ChargedSlotScope.entries.forEach { scope ->
val isSelected = scope == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(scope) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(scope.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun ChargedSlotScopeDialogPreview() = PreviewWrapper {
ChargedSlotScopeDialog(
current = ChargedSlotScope.PODS_AND_CASE,
onSelect = {},
onDismiss = {},
)
}
@@ -0,0 +1,75 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ConversationActionDialog(
current: ConversationAction,
onSelect: (ConversationAction) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
text = {
Column(Modifier.selectableGroup()) {
ConversationAction.entries.forEach { action ->
val isSelected = action == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(action) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(action.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun ConversationActionDialogPreview() = PreviewWrapper {
ConversationActionDialog(
current = ConversationAction.LOWER_VOLUME,
onSelect = {},
onDismiss = {},
)
}
@@ -61,9 +61,11 @@ import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
@@ -172,6 +174,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -194,6 +197,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -327,9 +331,10 @@ fun OverviewScreen(
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
isCollapsed = isCollapsed,
batteryEstimate = state.estimateFor(device),
onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) }
} else null,
@@ -351,18 +356,25 @@ fun OverviewScreen(
}
}
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
if (state.showTroubleshootSuggestion) {
item(key = "troubleshoot_suggestion") {
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
}
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
}
// 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) {
if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") {
UnmatchedDevicesCard(
count = state.unmatchedDevices.size,
count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched,
)
@@ -370,13 +382,13 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) {
itemsIndexed(
items = state.unmatchedDevices,
items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device ->
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onUpgrade = onUpgrade,
@@ -397,6 +409,7 @@ private fun PodDeviceCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit,
@@ -407,6 +420,7 @@ private fun PodDeviceCard(
device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -416,6 +430,7 @@ private fun PodDeviceCard(
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -434,11 +449,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.dualPodFullyLoaded(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -447,6 +462,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
showReactionsHint = true,
userExpandedIds = setOf("preview-single"),
batteryEstimates = mapOf(
"preview-dual-full" to BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
),
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -465,7 +490,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(),
@@ -488,7 +513,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = MockPodDataProvider.gplayInfo(),
@@ -511,7 +536,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = false,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
@@ -5,8 +5,7 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -22,7 +21,11 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -34,12 +37,16 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine as combineFlows
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import javax.inject.Inject
@@ -50,11 +57,12 @@ class OverviewViewModel @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -77,29 +85,90 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $permissions" }
private data class OverviewUiSettings(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
/**
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
*/
private val troubleshootSuggestion = combineFlows(
profilesRepo.profiles,
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
deviceMonitor.devices,
permissionTool.missingScanPermissions,
) { profiles, connectedDevices, devices, missingScanPermissions ->
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
val anyLivePod = devices.any { it.isLive }
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
}
.distinctUntilChanged()
.flatMapLatest { conditionMet ->
if (conditionMet) flow {
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
emit(true)
} else flowOf(false)
}
.onStart { emit(false) }
.distinctUntilChanged()
private val overviewUiSettings = combineFlows(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimates = batteryEstimates,
)
}
init {
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
// toggle so the section reappears collapsed if the user later disables the setting.
launch {
generalSettings.hideUnmatchedDevices.flow
.filter { it }
.collect { showUnmatchedDevices.value = false }
}
}
val workerAutolaunch = combine(
permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode,
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
// would silently delay autolaunch until the first connected-device emission.
bluetoothManager.connectedDevices
.onStart { emit(emptyList()) }
.map { it.isNotEmpty() }
.distinctUntilChanged(),
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
.distinctUntilChanged()
.onEach { (missing, mode, anyConnected) ->
if (missing.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $missing" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
val shouldStart = when (mode) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
try {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
} catch (e: SecurityException) {
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
false
}
}
MonitorMode.AUTOMATIC -> anyConnected
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
log(TAG) { "Starting monitor" }
log(TAG) { "Starting monitor (mode=$mode)" }
monitorControl.startMonitor()
}
}
@@ -126,15 +195,15 @@ class OverviewViewModel @Inject constructor(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
Bugs.isDebug,
bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow,
overviewUiSettings,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -143,13 +212,16 @@ class OverviewViewModel @Inject constructor(
now = timeSource.now(),
permissions = permissions,
devices = devices,
isDebugMode = isDebugMode,
isDebug = isDebug,
isBluetoothEnabled = isBluetoothEnabled,
profiles = profiles,
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -159,16 +231,25 @@ class OverviewViewModel @Inject constructor(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<PodDevice>,
val isDebugMode: Boolean,
val isDebug: Boolean,
val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
/**
* Time-remaining estimate to show for [device], or null when the device has the estimate
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
*/
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
/** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy {
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
@@ -186,6 +267,11 @@ class OverviewViewModel @Inject constructor(
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
val visibleUnmatchedDevices: List<PodDevice>
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
val bluetoothIconState: BluetoothIconState
get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN
@@ -224,6 +310,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index)
}
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager)
@@ -284,6 +375,14 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
/**
* How long the "connected but no live data" condition must hold before the troubleshooter
* hint appears, so it doesn't flash during the gap between an audio connection and the first
* BLE broadcast.
*/
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
}
}
@@ -60,12 +60,14 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant
@@ -76,6 +78,7 @@ fun DualPodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -192,6 +195,7 @@ fun DualPodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
@@ -233,6 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
PodGauge(
@@ -245,6 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
}
@@ -299,6 +309,8 @@ private fun PodGauge(
isMicrophone: Boolean,
showMicrophone: Boolean,
modifier: Modifier = Modifier,
timeRemaining: String? = null,
untilCharged: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -356,16 +368,27 @@ private fun PodGauge(
Spacer(modifier = Modifier.height(6.dp))
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage, with the time-remaining estimate inline next to it
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.alignByBaseline(),
)
if (timeRemaining != null) {
Text(
text = "· $timeRemaining",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline(),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
@@ -380,6 +403,7 @@ private fun PodGauge(
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label),
chargingDetail = untilCharged,
)
}
}
@@ -454,10 +478,49 @@ private fun CaseRow(
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
// enough live samples.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
),
onDeviceSettings = {},
)
}
@@ -57,10 +57,12 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@@ -73,6 +75,7 @@ fun SinglePodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -178,6 +181,7 @@ fun SinglePodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage + time remaining, stacked inside the ring
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
softWrap = false,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -277,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
AapPodState.ChargingState.CHARGING -> {
// Time-until-charged lives in the charging chip; the in-ring estimate
// always means runtime, so the two can't be confused.
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
?.let { formatBatteryDurationShort(context, it) }
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = untilCharged
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
?: stringResource(R.string.pods_charging_label),
)
}
else -> Unit
}
if (device.isBeingWorn == true) {
@@ -333,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
batteryEstimate = BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
@@ -0,0 +1,71 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Troubleshoot
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Troubleshoot,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onTroubleShooter,
modifier = Modifier.align(Alignment.End),
) {
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
}
}
}
}
@Preview2
@Composable
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
TroubleshootSuggestionCard(onTroubleShooter = {})
}
@@ -71,6 +71,7 @@ fun StatusChipRow(
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
chargingDetail: String? = null,
) {
FlowRow(
modifier = modifier.animateContentSize(),
@@ -82,9 +83,11 @@ fun StatusChipRow(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingOptimizedLabel,
)
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
// estimate line under the percentage always means runtime, so the two can't be confused.
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
)
else -> Unit
}
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
chargingDetail = "25m",
)
}
@@ -1,44 +1,32 @@
package eu.darken.capod.main.ui.settings.general
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.AccountTree
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.Contrast
import androidx.compose.material.icons.twotone.DarkMode
import androidx.compose.material.icons.twotone.DisabledVisible
import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
@@ -55,7 +43,6 @@ import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeState
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
@Composable
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
@@ -67,13 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
GeneralSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -86,20 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier,
) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -122,17 +106,6 @@ fun GeneralSettingsScreen(
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_monitor_mode_label),
subtitle = stringResource(state.monitorMode.labelRes),
icon = Icons.TwoTone.DisabledVisible,
onClick = { showMonitorModeDialog = true },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
}
@@ -229,10 +202,17 @@ fun GeneralSettingsScreen(
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_label),
subtitle = stringResource(R.string.settings_debug_description),
icon = Icons.TwoTone.BugReport,
onClick = onDebugSettings,
title = stringResource(R.string.settings_overview_hide_unmatched_label),
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
icon = Icons.TwoTone.VisibilityOff,
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
trailingContent = {
Switch(
checked = state.hideUnmatchedDevices,
onCheckedChange = onHideUnmatchedDevicesChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
@@ -286,20 +266,6 @@ fun GeneralSettingsScreen(
}
}
if (showMonitorModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_monitor_mode_label),
entries = MonitorMode.entries,
selectedEntry = state.monitorMode,
onEntrySelected = {
onMonitorModeSelected(it)
showMonitorModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showMonitorModeDialog = false },
)
}
if (showColorDialog) {
ThemeColorSelectorDialog(
selectedColor = state.themeState.color,
@@ -314,12 +280,12 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC,
showConnectedNotification = true,
keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(),
)
@@ -329,13 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = true),
onNavigateUp = {},
onMonitorModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -345,60 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = false),
onNavigateUp = {},
onMonitorModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
)
}
@Composable
private fun <T> ListPreferenceDialog(
title: String,
entries: List<T>,
selectedEntry: T,
onEntrySelected: (T) -> Unit,
entryLabel: @Composable (T) -> String,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = {
Column(Modifier.selectableGroup()) {
entries.forEach { entry ->
val isSelected = entry == selectedEntry
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onEntrySelected(entry) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = entryLabel(entry),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -13,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.themeState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -30,12 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
data class State(
val isPro: Boolean,
val monitorMode: MonitorMode,
val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState,
)
@@ -43,12 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine(
combine(
generalSettings.monitorMode.flow,
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, showNotif, keepNotif ->
) { showNotif, keepNotif ->
@Suppress("USELESS_CAST")
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
arrayOf<Any>(showNotif as Any, keepNotif as Any)
},
combine(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -60,24 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
},
generalSettings.themeState,
isPro,
) { general, compat, themeState, isPro ->
generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State(
isPro = isPro,
monitorMode = general[0] as MonitorMode,
showConnectedNotification = general[1] as Boolean,
keepNotificationAfterDisconnect = general[2] as Boolean,
showConnectedNotification = general[0] as Boolean,
keepNotificationAfterDisconnect = general[1] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState,
)
}.asLiveState()
fun setMonitorMode(mode: MonitorMode) {
log(TAG, INFO) { "setMonitorMode($mode)" }
generalSettings.monitorMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -103,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
}
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) {
@@ -135,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade)
}
fun goToDebugSettings() {
log(TAG, INFO) { "goToDebugSettings()" }
navTo(Nav.Settings.Debug)
}
companion object {
private val TAG = logTag("Settings", "General", "VM")
}
@@ -1,26 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
}
}
@@ -1,136 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.DataArray
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
@Composable
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
DebugSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
)
}
}
@Composable
fun DebugSettingsScreen(
state: DebugSettingsViewModel.State,
onNavigateUp: () -> Unit,
onDebugModeChanged: (Boolean) -> Unit,
onShowFakeDataChanged: (Boolean) -> Unit,
onShowUnfilteredChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_debug_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_mode_label),
subtitle = stringResource(R.string.settings_debug_mode_description),
icon = Icons.TwoTone.BugReport,
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
trailingContent = {
Switch(
checked = state.isDebugModeEnabled,
onCheckedChange = onDebugModeChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_fake_data_label),
subtitle = stringResource(R.string.settings_fake_data_description),
icon = Icons.TwoTone.DataArray,
onClick = { onShowFakeDataChanged(!state.showFakeData) },
trailingContent = {
Switch(
checked = state.showFakeData,
onCheckedChange = onShowFakeDataChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_blescanner_unfiltered_label),
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
icon = Icons.TwoTone.DevicesOther,
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
trailingContent = {
Switch(
checked = state.showUnfiltered,
onCheckedChange = onShowUnfilteredChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
}
}
}
@Preview2
@Composable
private fun DebugSettingsScreenPreview() = PreviewWrapper {
DebugSettingsScreen(
state = DebugSettingsViewModel.State(
isDebugModeEnabled = true,
showFakeData = false,
showUnfiltered = false,
),
onNavigateUp = {},
onDebugModeChanged = {},
onShowFakeDataChanged = {},
onShowUnfilteredChanged = {},
)
}
@@ -1,57 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val debugSettings: DebugSettings,
) : ViewModel4(dispatcherProvider) {
data class State(
val isDebugModeEnabled: Boolean,
val showFakeData: Boolean,
val showUnfiltered: Boolean,
)
val state = combine(
debugSettings.isDebugModeEnabled.flow,
debugSettings.showFakeData.flow,
debugSettings.showUnfiltered.flow,
) { debugMode, fakeData, unfiltered ->
State(
isDebugModeEnabled = debugMode,
showFakeData = fakeData,
showUnfiltered = unfiltered,
)
}.asLiveState()
fun setDebugModeEnabled(enabled: Boolean) {
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
debugSettings.isDebugModeEnabled.valueBlocking = enabled
}
fun setShowFakeData(enabled: Boolean) {
log(TAG, INFO) { "setShowFakeData($enabled)" }
debugSettings.showFakeData.valueBlocking = enabled
}
fun setShowUnfiltered(enabled: Boolean) {
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
debugSettings.showUnfiltered.valueBlocking = enabled
}
companion object {
private val TAG = logTag("Settings", "Debug", "VM")
}
}
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.takeFor
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
fun batteryEstimator(): BatteryEstimator
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
// Empty while the monitor service isn't running — estimates simply stay hidden.
val rawEstimate by config.profileId
?.let { pid ->
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
}
?.collectAsState(initial = null)
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
)
} else {
WidgetRenderState.Message(
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
)
).withLocalizedPreviewEstimates(context)
provideContent {
GlanceWidgetContent(state = previewState, context = context)
@@ -96,9 +96,9 @@ private fun DualPodPreview(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
DeviceLabel(
label = state.deviceLabel,
@@ -111,8 +111,8 @@ private fun DualPodPreview(
BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
DeviceLabel(
label = state.deviceLabel,
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -297,39 +298,54 @@ private fun PodItemRow(
textColor: Color,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: Modifier = Modifier,
) {
Row(
Column(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
fontSize = 12.sp,
color = textColor,
maxLines = 1,
)
}
}
@@ -385,6 +401,8 @@ private fun DeviceLabel(
private fun formatPercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@Preview2
@Composable
private fun PreviewDualTiny11() = PreviewWrapper {
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -257,37 +258,49 @@ private fun GlancePodItem(
textStyle: TextStyle,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
Column(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
style = textStyle,
maxLines = 1,
)
}
}
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel)
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
}
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
}
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val isAapConnected: Boolean,
val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
enum class BatteryLayout {
TINY_COLUMN,
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
val leftEstimate: String?,
@DrawableRes val rightIcon: Int,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
val rightEstimate: String?,
@DrawableRes val caseIcon: Int,
val casePercent: Float,
val caseCharging: Boolean,
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
val estimate: String?,
) : WidgetRenderState()
data class Message(
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
leftPercent = 0.85f,
leftCharging = false,
leftInEar = true,
leftEstimate = "4h 55m",
rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f,
rightCharging = true,
rightInEar = false,
rightEstimate = "25m",
caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f,
caseCharging = false,
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
estimate = "14h 30m",
)
}
}
/**
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
*/
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
leftEstimate = formatBatteryDurationShort(context, 295),
rightEstimate = formatBatteryDurationShort(context, 25),
)
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
object WidgetRenderStateMapper {
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
hasConfiguredProfile: Boolean,
profileLabel: String?,
layout: BatteryLayout = BatteryLayout.NARROW,
estimate: BatteryEstimate? = null,
): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme)
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
caseIcon = device.caseIcon,
casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true,
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
)
device != null -> WidgetRenderState.Message(
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
}
}
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
@ColorInt
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
val bgColor = theme.backgroundColor
@@ -79,6 +79,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile?.address in connectedAddresses,
)
}
@@ -115,6 +116,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in state.connectedAddresses,
)
}
@@ -181,6 +183,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in connectedAddresses,
)
}
@@ -212,6 +215,21 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope)
/**
* Connection-aware "primary" device for display surfaces (ongoing notification, case popup):
* ranks by [PodDevice.tierRank] (system-connected > live > offline) with the user's
* profile-list order as the tiebreaker. Unlike [primaryDevice], this does NOT blindly pick the
* first profile, so a worn/connected pod is surfaced even when a different profile sits higher
* in the list. Shared so multiple display collectors don't each re-run the combine.
*/
val primaryDeviceByTier: Flow<PodDevice?> = combine(
devices,
profilesRepo.profiles,
) { devices, profiles ->
val profileOrder = profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
devices.primaryByTier(profileOrder)
}.replayingShare(appScope)
private val reportedPersistFailures = mutableSetOf<String>()
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
@@ -273,6 +291,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
)
}
@@ -0,0 +1,51 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.toReactionConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MonitorModeResolver @Inject constructor(
private val profilesRepo: DeviceProfilesRepo,
private val nudgeCapabilityStore: NudgeCapabilityStore,
) {
val effectiveMode: Flow<MonitorMode> = combine(
profilesRepo.profiles,
nudgeCapabilityStore.availability,
) { profiles, nudge ->
// The mode is driven by the first *addressed* profile, not just the first profile. An
// address-less profile can neither host an AAP session (AapAutoConnect requires an address)
// nor auto-connect, so it can only ever resolve to MANUAL. Letting such a profile sit at the
// top of the list and force MANUAL would silently stop the foreground service that keeps the
// process — and thus background AAP/stem controls — alive for an addressed profile below it.
// No addressed profile at all => genuinely nothing to monitor => MANUAL.
profiles.firstOrNull { !it.address.isNullOrBlank() }?.requiredMode(nudge)
?: MonitorMode.MANUAL
}
.distinctUntilChanged()
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
private fun DeviceProfile.requiredMode(nudge: NudgeAvailability): MonitorMode = when {
// Match AutoConnect.kt's isNullOrEmpty check — a blank/legacy "" address is no address.
address.isNullOrBlank() -> MonitorMode.MANUAL
toReactionConfig().autoConnect && nudge != NudgeAvailability.BROKEN -> MonitorMode.ALWAYS
else -> MonitorMode.AUTOMATIC
}
companion object {
private val TAG = logTag("Monitor", "ModeResolver")
}
}
@@ -60,6 +60,8 @@ data class PodDevice(
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(),
/** Whether the dashboard battery time-remaining estimate is enabled for this device (per-profile). */
val batteryEstimateEnabled: Boolean = true,
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
val isSystemConnected: Boolean = false,
) {
@@ -181,18 +183,23 @@ data class PodDevice(
).minOrNull()
}
// Charging — AAP preferred, BLE fallback, then cached
// Charging — AAP preferred, BLE fallback, then cached. The cached fallback only applies while
// the device is live (connected): charging is a real-time claim ("power is flowing now") that
// we can't stand behind once the device is out of range, so we suppress it after full
// disconnect rather than showing a frozen charging state on the dimmed "cached N ago" card.
// The raw charging bits are still persisted in `cached` for callers that want the last-known
// value directly (device.cached?.is…Charging).
val isLeftPodCharging: Boolean?
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging?.takeIf { isLive }
val isRightPodCharging: Boolean?
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging?.takeIf { isLive }
val isCaseCharging: Boolean?
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging?.takeIf { isLive }
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging?.takeIf { isLive }
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
@@ -267,6 +274,14 @@ data class PodDevice(
val isEitherPodInEar: Boolean?
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
/**
* True when ear detection comes from a live AAP reading. The BLE ear-detection bits are
* unreliable for pods resting in the case (they report phantom "in ear" and disagree
* between the two pods' advertisements), so consumers that must not act on a false "worn"
* gate on this.
*/
val hasAapEarDetection: Boolean get() = aap?.aapEarDetection != null
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
@@ -5,8 +5,13 @@ package eu.darken.capod.monitor.core
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
* Backs [DeviceMonitor.primaryDeviceByTier], used by display surfaces (ongoing notification,
* case popup) that should follow the worn/connected pod.
*
* Distinct from [DeviceMonitor.primaryDevice], which is intentionally non-tier-ranked: address-
* and eligibility-gated reaction flows (auto-connect targets a not-yet-connected device; sleep and
* conversation match by the event's source address) must keep "first profiled device" semantics,
* which tier ranking would break.
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
@@ -9,20 +9,22 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -37,6 +39,21 @@ class AapAutoConnect @Inject constructor(
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
/**
* Per-address count of consecutive connection attempts that never reached READY (failed socket
* connects and handshake-timeout disconnects). Drives the escalating reconnect cooldown so a
* device that keeps stalling its handshake in a crowded RF environment doesn't churn a tight
* reconnect loop. Reset to 0 once a session works (a was-ready disconnect) or the device leaves.
*/
private val preReadyFailures = ConcurrentHashMap<String, Int>()
/** Cooldown (ms) to wait before the next attempt, given how many consecutive pre-READY failures we've seen. */
private fun cooldownFor(address: String): Long {
val failures = preReadyFailures[address] ?: 0
if (failures <= 0) return 0L
return HANDSHAKE_BACKOFF[minOf(failures - 1, HANDSHAKE_BACKOFF.lastIndex)]
}
fun monitor(): Flow<Unit> = merge(
initialConnect(),
reconnectOnDisconnect(),
@@ -74,104 +91,150 @@ class AapAutoConnect @Inject constructor(
return
}
// Escalating cooldown carried over from prior failed attempts (socket failures or handshake
// stalls). 0 on the first attempt so a healthy device connects without delay.
val cooldown = cooldownFor(address)
if (cooldown > 0) {
log(TAG) { "AAP initial connect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
delay(cooldown)
}
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address" }
return
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Bail out if classic BT disconnected
// Bail out if classic BT disconnected — device left, clear the penalty
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
break
preReadyFailures.remove(address)
return
}
val retryState = aapManager.allStates.value[address]
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
break
return
}
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
return
} catch (retryException: Exception) {
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
}
}
// Every attempt this episode failed at the socket level — escalate the next round's cooldown.
preReadyFailures.merge(address, 1, Int::plus)
}
}
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
.onEach { address ->
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return@onEach
}
try {
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
break
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
break
}
// Check if still classically connected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
break
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.meta?.profile?.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
break
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
break
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
} finally {
activeReconnects.remove(address)
}
// Process each disconnect on its own child coroutine so a long per-address backoff never blocks
// another device's reconnect (the collector would otherwise serialise events). channelFlow gives
// us a scope to launch into; it intentionally emits nothing — it stays subscribed for its lifetime.
private fun reconnectOnDisconnect(): Flow<Unit> = channelFlow<Unit> {
aapManager.disconnectEvents.collect { event ->
launch { handleReconnect(event) }
}
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.setupCommonEventHandlers(TAG) { "reconnect" }
}.setupCommonEventHandlers(TAG) { "reconnect" }
private suspend fun handleReconnect(event: AapDisconnectEvent) {
val address = event.address
// A session that actually worked resets the penalty (prompt reconnect). One that never reached
// READY is a failed handshake/short session — escalate so a persistent stall backs off.
if (event.wasEverReady) {
preReadyFailures.remove(address)
} else {
preReadyFailures.merge(address, 1, Int::plus)
}
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return
}
try {
// Escalating cooldown before the normal retry cadence when handshakes keep stalling.
val cooldown = cooldownFor(address)
if (cooldown > 0) {
log(TAG) { "AAP reconnect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
delay(cooldown)
}
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still classically connected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.meta?.profile?.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
preReadyFailures.remove(address)
return
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
return
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
return
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
// Every attempt threw at the socket level (the device is still present but won't accept a
// socket) — escalate so we don't hammer it. Mirrors connectWithRetries. Handshake stalls
// don't reach here: their reconnect socket succeeds and we return above; they escalate via
// the never-ready disconnect event instead.
preReadyFailures.merge(address, 1, Int::plus)
} finally {
activeReconnects.remove(address)
}
}
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
@@ -224,5 +287,12 @@ class AapAutoConnect @Inject constructor(
companion object {
private val TAG = logTag("Monitor", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
/**
* Escalating cooldown (ms) applied before a reconnect/connect attempt, indexed by
* consecutive pre-READY failures minus one. Caps at 60s so a device that perpetually stalls
* its handshake in crowded RF settles into a slow ~minute cadence instead of a tight loop.
*/
internal val HANDSHAKE_BACKOFF = longArrayOf(5_000, 15_000, 30_000, 60_000)
}
}
@@ -66,7 +66,7 @@ class StemPressReaction @Inject constructor(
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
is StemAction.Stop -> mediaControl.sendStop()
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
@@ -0,0 +1,120 @@
package eu.darken.capod.monitor.core.battery
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
*/
@Singleton
class BatteryDrainStore @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val storeDir by lazy {
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
}
private val lock = Mutex()
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
init {
appScope.launch { loadAll() }
}
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
val dir = storeDir
if (!dir.exists()) return@withLock
val loaded = mutableMapOf<ProfileId, DrainProfile>()
dir.listFiles()
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
?.forEach { file ->
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
try {
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
file.delete()
}
}
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
_profiles.value = loaded
}
}
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
val file = id.toFile()
val tmp = File(file.parentFile, "${file.name}.tmp")
try {
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
if (!tmp.renameTo(file)) {
// renameTo can fail across some filesystems; fall back to a direct write.
file.writeText(tmp.readText())
tmp.delete()
}
_profiles.value += (id to profile)
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
tmp.delete()
}
}
}
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
val file = id.toFile()
if (file.exists() && !file.delete()) {
// In-memory is cleared regardless; warn so a failed delete (which would resurrect the
// rate on the next loadAll()) is at least visible rather than silently undoing a reset.
log(TAG, Logging.Priority.ERROR) { "delete($id): failed to remove $file" }
}
_profiles.value -= id
}
}
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
storeDir.listFiles()?.forEach { it.delete() }
_profiles.value = emptyMap()
}
}
companion object {
private val TAG = logTag("Monitor", "BatteryDrainStore")
private const val PREFIX = "drainrate_"
private const val SUFFIX = ".json"
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.monitor.core.battery
/**
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
* [right] for dual-pod devices. Slots with no usable estimate are null.
*/
data class BatteryEstimate(
val left: Pod? = null,
val right: Pod? = null,
val headset: Pod? = null,
) {
/**
* @property minutesRemaining smoothed estimate of minutes until this pod empties
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
* bounded by the model's rated life
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
* the gauge line as the runtime projection.
*/
data class Pod(
val minutesRemaining: Int,
val fractionPerHour: Float,
val source: Source,
val minutesUntilCharged: Int? = null,
) {
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
val isProvisional: Boolean get() = source == Source.SPEC
}
/** Where a pod's drain rate was derived from, in order of increasing confidence. */
enum class Source {
/** Apple's published rating — shown immediately on connect, before any drain is observed. */
SPEC,
/** Persisted rate from earlier sessions with this device. */
LEARNED,
/** Measured from the current session's observed drain. */
LIVE,
}
val hasAny: Boolean get() = left != null || right != null || headset != null
}
@@ -0,0 +1,40 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.PodDevice
/**
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
* device is live (no estimates for cached/offline cards), and it maps to a profile.
*/
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
device.batteryEstimateEnabled && device.isLive && device.profileId != null
}
/**
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
* widget, notification) so the display rules can't drift apart.
*/
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
device.profileId?.let { this[it] }?.takeFor(device)
/**
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
* projection. [charging] should be the same flag that drives the visible charging indicator,
* so the shown duration never contradicts the bolt icon next to it.
*/
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
charging -> minutesUntilCharged
else -> minutesRemaining
}
/**
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
* anything. Everything else on the estimate (rates, source) churns without visible effect —
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
*/
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
left?.displayMinutes(device.isLeftPodCharging == true),
right?.displayMinutes(device.isRightPodCharging == true),
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
).takeUnless { key -> key.all { it == null } }
@@ -0,0 +1,722 @@
package eu.darken.capod.monitor.core.battery
import android.media.AudioManager
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* Learns each device's battery drain rate from observed levels over time and turns it into a
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
* is available immediately on reconnect.
*
* Lifecycle: [monitor] is launched by the foreground monitor service (see
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
*/
@Singleton
class BatteryEstimator @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val drainStore: BatteryDrainStore,
private val timeSource: TimeSource,
private val audioManager: AudioManager,
) {
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
private enum class Slot { LEFT, RIGHT, HEADSET }
/** Which transport a battery reading came from — AAP is 1% granularity, BLE 10%. */
private enum class DataSource { AAP, BLE }
private class SlotHistory {
enum class Direction { DRAIN, CHARGE }
var direction: Direction = Direction.DRAIN
private set
private var source: DataSource? = null
private val samples = ArrayDeque<DrainSample>()
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
val size: Int get() = samples.size
/**
* Keeps the window only while it still describes the same thing: a direction flip
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
*/
fun matches(direction: Direction, source: DataSource): Boolean =
this.direction == direction && this.source == source
fun realign(direction: Direction, source: DataSource) {
if (!matches(direction, source)) samples.clear()
this.direction = direction
this.source = source
}
fun record(sample: DrainSample) {
samples.addLast(sample)
while (samples.size > RING_SIZE) samples.removeFirst()
}
fun clear() = samples.clear()
fun toList(): List<DrainSample> = samples.toList()
}
private class DeviceTracker {
var modeBucket: String = MODE_UNKNOWN
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/**
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
* is the system's audio sink — pure listening segments, the basis for battery health.
*/
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
/** Smoothed displayed minutes, per pod. */
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
var lastUpdateMs: Long? = null
/** When each slot's level last visibly ROSE while charging — drives stall suppression. */
val lastRiseMs: MutableMap<Slot, Long> = mutableMapOf()
// Keyed by "<bucket>/<slot>" for drain rates, "CHARGE/<slot>" for charge rates.
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
/**
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
* during a single session blend against a fixed baseline instead of runaway-converging.
* [sessionBaselineCounts] captures the matching pre-session updateCount, so repeated persists
* within one session count as ONE update, not many.
*/
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
val sessionBaselineCounts: MutableMap<String, Int> = mutableMapOf()
fun clearSlots() = slots.values.forEach { it.clear() }
fun resetWindow() {
clearSlots()
listeningSlots.values.forEach { it.clear() }
pendingListeningFits.clear()
lastMinutes.clear()
lastRiseMs.clear()
sessionBaseline.clear()
sessionBaselineCounts.clear()
}
}
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
// Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate.
private val mutex = Mutex()
/**
* Wipes ALL learned state for [profileId] — the in-memory tracker, the current estimate, and the
* persisted rates — under the same lock as [process], so it's atomic w.r.t. sampling/persisting.
* The next live emission re-seeds the device from its model rating. Safe to call when the monitor
* isn't running (trackers/estimates are already empty; only the store delete has effect).
*/
suspend fun reset(profileId: ProfileId) = withContext(NonCancellable) {
// NonCancellable so a cancelled caller (e.g. the settings screen closing) can't leave the
// wipe half-applied — memory cleared but persisted rates surviving to resurrect later.
mutex.withLock {
log(TAG) { "reset($profileId)" }
trackers.remove(profileId)
_estimates.value = _estimates.value - profileId
drainStore.delete(profileId)
}
}
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices -> process(devices) }
.onCompletion {
// The collector stops with the monitor service; drop session state so the UI can't
// keep showing a stale estimate and the next session re-seeds from persistence.
trackers.clear()
_estimates.value = emptyMap()
}
.map { }
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
private suspend fun process(devices: List<PodDevice>) = mutex.withLock {
// Only profiles with a single, unambiguous live candidate that has the estimate enabled.
// DeviceMonitor keeps multiple same-profile devices when there's no IRK-verified match, and
// blending two physical devices' levels would be garbage — skip those. A device with the
// feature disabled is skipped entirely (not sampled, not persisted).
val unambiguous = devices
.filter { it.profileId != null && it.isLive && it.batteryEstimateEnabled }
.groupBy { it.profileId!! }
.filter { (_, group) -> group.size == 1 }
.mapValues { (_, group) -> group.single() }
val next = _estimates.value.toMutableMap()
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
next.keys.retainAll(unambiguous.keys)
// Sampled once per emission — the gate for health-grade "listening" segments.
val musicActive = audioManager.isMusicActive
for ((profileId, device) in unambiguous) {
val estimate = updateTracker(profileId, device, musicActive)
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
}
_estimates.value = next
}
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
val nowMs = timeSource.elapsedRealtime()
val bucket = device.modeBucket()
// A long gap since the last update means the device was out of range / reconnected — the
// prior window is stale and must not be extended across the absence.
val gap = tracker.lastUpdateMs?.let { nowMs - it }
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
tracker.lastUpdateMs = nowMs
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
// then start fresh so OFF-rate samples never blend into ON-rate.
if (tracker.modeBucket != bucket) {
persistFromWindow(profileId, tracker, device, tracker.modeBucket, nowMs, force = true)
tracker.resetWindow()
tracker.modeBucket = bucket
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val charging = device.liveCharging(slot)
val reading = device.liveReading(slot)
when {
reading == null -> { // unavailable reading → just drop this slot's window
history.clear()
tracker.lastRiseMs.remove(slot)
// A charging jump breaks discharge continuity even when the level is unreadable.
if (charging == true) tracker.lastMinutes.remove(slot)
}
charging == true -> { // battery rising → sample the CHARGE, never a drain
val (fraction, source) = reading
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop discharge smoothing
if (device.liveChargingOptimized(slot)) {
// Optimized Battery Charging parks the level below full for hours while
// still flagged charging — fitting that plateau would learn garbage.
history.clear()
tracker.lastRiseMs.remove(slot)
} else {
history.realign(SlotHistory.Direction.CHARGE, source)
val last = history.lastFraction
when {
last == null -> { // charge session starts (or resumes) for this slot
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction > last + EPSILON -> {
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction < last - EPSILON -> { // level DROPPED while charging → reseat/swap
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
else -> Unit // ~unchanged; stall detection judges the silence
}
}
}
else -> { // draining (or unknown charging state — treated as draining, as before)
val (fraction, source) = reading
tracker.lastRiseMs.remove(slot)
history.realign(SlotHistory.Direction.DRAIN, source)
val last = history.lastFraction
when {
last == null -> history.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastMinutes.remove(slot)
}
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
else -> Unit // ~unchanged, no new information
}
}
}
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
// and this device the audio sink (isMusicActive alone would count phone-speaker
// playback). The moment the gate breaks, the finished segment's fit is captured for
// persistence and the window cleared; mixed idle/listening samples would flatten the
// slope and re-dilute health.
val listening = charging != true && reading != null &&
musicActive && device.isSystemConnected && device.wornForSlot(slot)
val listeningHistory = tracker.listeningSlots.getValue(slot)
if (listening) {
val (fraction, source) = reading!!
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
// than letting realign silently discard it.
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
captureListeningSegment(tracker, slot)
}
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
val last = listeningHistory.lastFraction
when {
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
listeningHistory.clear()
listeningHistory.record(DrainSample(nowMs, fraction))
}
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
else -> Unit
}
} else {
captureListeningSegment(tracker, slot)
}
}
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
return computeEstimate(profileId, tracker, device, bucket, nowMs)
}
/** Computes an independent estimate for each pod (left / right / headset). */
private fun computeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
): BatteryEstimate? {
val estimate = BatteryEstimate(
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT, nowMs),
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT, nowMs),
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET, nowMs),
)
return estimate.takeIf { it.hasAny }
}
private fun slotEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
slot: Slot,
nowMs: Long,
): BatteryEstimate.Pod? {
val fraction = device.liveFraction(slot) ?: return null
val spec = device.specRate(bucket)
// While charging the battery is rising, so there's no live drain to fit — project the runtime
// "if used now" from the learned rate or the model rating instead. Live sampling is skipped
// here (and updateTracker already clears the window while charging), so a rising level is
// never learned as a drain — we only surface a projection so the estimate stays visible in
// the case, climbing as the pod charges.
val charging = device.liveCharging(slot) == true
val live = if (charging) {
null
} else {
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
?.takeIf { plausibleForModel(it, spec) }
}
val learned = learnedRate(profileId, device, bucket, slot)
val displayRate = live ?: learned ?: spec ?: return null
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
// source: a degraded battery only ever drains FASTER than new-condition spec, so a measured
// rate normally wins — spec only bites when a source implausibly implies MORE life than Apple.
val effectiveRate = spec?.let { maxOf(displayRate, it) } ?: displayRate
val minutes = DrainModel.minutesRemaining(fraction, effectiveRate) ?: return null
// Smooth against the last displayed value — but while charging neither read nor write that
// state: the in-case projection is shown raw and must not pollute the discharge history, else
// the first estimate after undocking would blend against a stale charging projection.
var smoothed = DrainModel.blendMinutes(if (charging) null else tracker.lastMinutes[slot], minutes)
// Smoothing lags, so just after a drop it can sit above the ceiling — clamp the shown value to
// the spec cap too, so we never DISPLAY more life than Apple rates even mid-transition.
spec?.let { DrainModel.minutesRemaining(fraction, it) }?.let { smoothed = minOf(smoothed, it) }
if (!charging) tracker.lastMinutes[slot] = smoothed
val source = when {
live != null -> BatteryEstimate.Source.LIVE
learned != null -> BatteryEstimate.Source.LEARNED
else -> BatteryEstimate.Source.SPEC
}
return BatteryEstimate.Pod(
minutesRemaining = smoothed,
fractionPerHour = effectiveRate,
source = source,
minutesUntilCharged = if (charging) chargeEstimate(profileId, tracker, device, slot, fraction, nowMs) else null,
)
}
/**
* Minutes until [slot] is full, or null when no usable charge rate exists, the pod is in an
* Optimized Battery Charging hold, or the level has sat still longer than one visible step
* should take (stall — trickle phase or an unreported hold; a linear ETA would just freeze).
*/
private fun chargeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
slot: Slot,
fraction: Float,
nowMs: Long,
): Int? {
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
val history = tracker.slots.getValue(slot)
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
val learnedScalar = learnedChargeRate(profileId, device, slot)
val specRate = device.model.batterySpec?.chargeFractionPerHour
// Per band: this session's in-band fit, then the learned band rate, then the scalar
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
// measures the bulk phase; scalars already average what was actually observed).
fun rateFor(band: DrainModel.ChargeBand): Float? =
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
?: learnedChargeBand(profileId, device, slot, band)
?: liveScalar
?: learnedScalar
?: specRate?.let { it * band.specMultiplier }
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
?: DrainModel.ChargeBand.TRICKLE
val stallRate = rateFor(currentBand) ?: return null
val lastRise = tracker.lastRiseMs[slot] ?: return null
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
return DrainModel.minutesUntilFull(fraction, ::rateFor)
}
private fun learnedChargeBand(
profileId: ProfileId,
device: PodDevice,
slot: Slot,
band: DrainModel.ChargeBand,
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
/**
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
* persist pass writes it, bypassing cadence) and the window cleared either way.
*/
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
val history = tracker.listeningSlots.getValue(slot)
if (history.size == 0) return
DrainModel.slopeFractionPerHour(history.toList())?.let {
tracker.pendingListeningFits[slot] = it to history.size
}
history.clear()
}
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> isLeftInEar
Slot.RIGHT -> isRightInEar
Slot.HEADSET -> isBeingWorn
} == true
/**
* Persists each pod's live drain or charge rate (whichever direction its window currently
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
* unless [force]d (mode change). Drain rates are keyed per (bucket, slot); charge rates per slot
* only — the ANC mode doesn't apply inside the case.
*/
private suspend fun persistFromWindow(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
force: Boolean,
) {
// A profile tagged with DIFFERENT hardware means the user re-pointed it — its rates don't
// describe this device, so learning starts over instead of blending into foreign history.
val existing = drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
?: DrainProfile()
val spec = device.specRate(bucket)
var rates = existing.rates
var chargeRates = existing.chargeRates
var changed = false
var chargeBands = existing.chargeBands
var listeningRates = existing.listeningRates
// Blend against the rate stored when this session began, captured once per key, so a single
// long session's repeated writes can't dominate prior history by re-blending their own
// output. The captured updateCount keeps a whole session counting as ONE accumulated update.
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
}
return DrainProfile.LearnedRate(
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
sampleCount = samples,
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 0) + 1,
updatedAt = timeSource.now(),
)
}
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
val last = tracker.lastPersistAtMs[cadenceKey]
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
tracker.lastPersistAtMs[cadenceKey] = nowMs
return true
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
if (history.direction == SlotHistory.Direction.CHARGE) {
// Whole-session scalar — the fallback basis and the stall-threshold reference.
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
val key = chargeRateKey(slot)
if (cadenceOk(key)) {
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
// Per-band rates — charging is CC/CV, each regime learns its own speed.
for (band in DrainModel.ChargeBand.entries) {
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
val key = "${chargeRateKey(slot)}/${band.name}"
if (!cadenceOk(key)) continue
val stored = chargeBands[slot.name]?.get(band.name)
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
chargeBands = chargeBands + (slot.name to slotBands)
changed = true
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
} else {
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
DrainModel.slopeFractionPerHour(history.toList())
?.takeIf { plausibleForModel(it, spec) }
?.let { fit ->
val key = rateKey(bucket, slot)
if (cadenceOk(key)) {
rates = rates + (key to blended(key, rates[key], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
// Listening rates (health basis): a just-closed segment persists immediately — it would
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
val pending = tracker.pendingListeningFits.remove(slot)
val (fit, samples) = when {
pending != null -> pending
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
}
if (fit != null && plausibleForModel(fit, spec)) {
val key = rateKey(bucket, slot)
val cadenceKey = "LISTEN/$key"
if (cadenceOk(cadenceKey, always = pending != null)) {
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
changed = true
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
if (changed) {
drainStore.save(
profileId,
existing.copy(
model = device.model.name,
rates = rates,
chargeRates = chargeRates,
chargeBands = chargeBands,
listeningRates = listeningRates,
),
)
}
}
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null
// Exact per-mode learning always wins — a real measurement for THIS mode.
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
// unlearned mode never inflates the estimate past a real sibling measurement.
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
val sibling = siblingScaledRate(profile, device, bucket, slot)
return listOfNotNull(unknown, sibling).maxOrNull()
}
/**
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
*
* Only fires when:
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
* conservative spec-min behaviour), and
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
* or display clamp, so a borrowed rate could over-promise unbounded), and
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
* hardware can't use), falling back to all modes only when the supported list is unavailable.
*
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
* merges the result conservatively with the UNKNOWN reading.
*/
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
if (bucket == MODE_UNKNOWN) return null
val targetSpec = device.specRate(bucket) ?: return null
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
val best = AapSetting.AncMode.Value.entries
.map { it.name }
.filter { it != bucket && (supported == null || it in supported) }
.mapNotNull { sib ->
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
val learned = profile.rates[rateKey(sib, slot)]
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
?: return@mapNotNull null
learned to siblingSpec
}
.maxWithOrNull(
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
.thenBy { -abs(targetSpec - it.second) }
.thenBy { it.first.updatedAt },
) ?: return null
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
}
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
fractionPerHour.takeIf { it.isFinite() && it > 0f }
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
/** The stored profile, ignored entirely when its rates were learned on different hardware. */
private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? =
drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
/** Session-state key for charge windows — namespaced so it can't collide with an ANC bucket. */
private fun chargeRateKey(slot: Slot): String = "CHARGE/${slot.name}"
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
/**
* Apple's rated drain (fraction/hour) for this model in the given ANC bucket, or null when the
* model has no published rating (Beats / unknown). Used to seed an estimate before any drain is
* observed and as the upper bound on remaining life. When the mode isn't known yet (BLE-only, or
* a just-connected AAP session) the shorter of the two ratings is used, so an unknown mode can't
* over-promise.
*/
private fun PodDevice.specRate(bucket: String): Float? {
val spec = model.batterySpec ?: return null
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
val hours = when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
MODE_UNKNOWN -> listOfNotNull(on, off).minOrNull()
else -> on ?: off // ON / TRANSPARENCY / ADAPTIVE
} ?: return null
return if (hours.isFinite() && hours > 0f) 1f / hours else null
}
/**
* A measured rate is trusted only when it isn't absurdly faster than the model's rated drain. The
* slow side is intentionally NOT bounded: a genuinely gentle drain is real data worth learning,
* and the spec ceiling already stops a slow rate from over-reporting on screen.
*/
private fun plausibleForModel(rate: Float, spec: Float?): Boolean =
spec == null || rate <= spec * SPEC_BAND_MAX
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
// cache); learning from a re-stamped stale reading would poison the rate.
private fun PodDevice.liveFraction(slot: Slot): Float? = liveReading(slot)?.first
/**
* The slot's live battery fraction plus which transport reported it. The source matters because
* the two granularities (AAP 1%, BLE 10%) can't share a fit window — see [SlotHistory.realign].
*/
private fun PodDevice.liveReading(slot: Slot): Pair<Float, DataSource>? {
val aapValue = when (slot) {
Slot.LEFT -> aap?.batteryLeft
Slot.RIGHT -> aap?.batteryRight
Slot.HEADSET -> aap?.batteryHeadset
}
val (value, source) = when {
aapValue != null -> aapValue to DataSource.AAP
else -> when (slot) {
Slot.LEFT -> (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
Slot.RIGHT -> (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
}?.let { it to DataSource.BLE } ?: return null
}
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source }
}
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
}
/** Only AAP reports the Optimized Battery Charging hold; BLE can't distinguish it. */
private fun PodDevice.liveChargingOptimized(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> aap?.leftChargingState
Slot.RIGHT -> aap?.rightChargingState
Slot.HEADSET -> aap?.headsetChargingState
} == AapPodState.ChargingState.CHARGING_OPTIMIZED
companion object {
private val TAG = logTag("Monitor", "BatteryEstimator")
private const val RING_SIZE = 32
private const val EPSILON = 0.001f
private const val MODE_UNKNOWN = DrainProfile.BUCKET_UNKNOWN
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
/** Visible battery step per transport — feeds the granularity-aware stall threshold. */
private const val STEP_AAP = 0.01f
private const val STEP_BLE = 0.10f
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
private const val SPEC_BAND_MAX = 4f
/** A gap longer than this between updates means the device was away — reset its window. */
private const val STALE_GAP_MS = 15 * 60_000L
}
}
@@ -0,0 +1,86 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.math.roundToInt
/**
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
*
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
* the UI.
*/
object BatteryHealth {
/** A learned rate must have accumulated this many separate sessions before it counts. */
const val MIN_UPDATE_COUNT = 3
data class PerPod(
val left: Int? = null,
val right: Int? = null,
val headset: Int? = null,
) {
val hasAny: Boolean get() = left != null || right != null || headset != null
}
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
return PerPod(
left = slotPercent(profile, spec, "LEFT"),
right = slotPercent(profile, spec, "RIGHT"),
headset = slotPercent(profile, spec, "HEADSET"),
).takeIf { it.hasAny }
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
// future-format data and must not feed a health figure.
val parts = key.split('/')
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
(1f / specHours) / rate.fractionPerHour
}
if (ratios.isEmpty()) return null
val sorted = ratios.sorted()
val median = if (sorted.size % 2 == 1) {
sorted[sorted.size / 2]
} else {
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
}
return (median * 100f).roundToInt().coerceIn(1, 100)
}
/**
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
* the shorter one would systematically flatter health, the longer one would slander it.
* Malformed or unrecognized bucket keys yield null (entry is skipped).
*/
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
return when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
AapSetting.AncMode.Value.ON.name,
AapSetting.AncMode.Value.TRANSPARENCY.name,
AapSetting.AncMode.Value.ADAPTIVE.name,
-> on ?: off
DrainProfile.BUCKET_UNKNOWN -> listOfNotNull(on, off).takeIf { it.isNotEmpty() }?.average()?.toFloat()
else -> null
}?.takeIf { it.isFinite() && it > 0f }
}
}
@@ -0,0 +1,262 @@
package eu.darken.capod.monitor.core.battery
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* A single battery observation for one slot (left / right / headset).
*
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
*/
data class DrainSample(
val atElapsedMs: Long,
val fraction: Float,
)
/**
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
* be unit-tested without Android, coroutines, or persistence.
*
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
*/
object DrainModel {
/** Minimum samples before a live regression is trusted. */
const val MIN_SAMPLES = 4
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
const val MIN_SPAN_MS = 3 * 60_000L
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
const val MIN_TOTAL_DROP = 0.03f
/**
* Only samples within this window of the newest one feed the regression. Drops stale points
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
* hours of absence.
*/
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
const val RATE_MIN = 0.02f
const val RATE_MAX = 0.80f
/**
* Charge fits get by with fewer samples than drain fits: a charge session is short (well under
* an hour) and BLE's 10% steps would otherwise need most of the session before a fit exists.
*/
const val MIN_SAMPLES_CHARGE = 3
/** Minimum total rise across the charge window — rejects jitter that isn't a real charge. */
const val MIN_TOTAL_RISE = 0.05f
/** Plausible charge-rate band (fraction/hour): a full charge in 15 minutes .. 4 hours. */
const val CHARGE_RATE_MIN = 0.25f
const val CHARGE_RATE_MAX = 4.0f
/**
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
* tail, so suppression only covers the last sliver where the firmware is about to flip the
* charging flag off at 100% anyway.
*/
const val NEAR_FULL_SUPPRESS = 0.99f
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
const val MIN_SAMPLES_CHARGE_NARROW = 2
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
const val MIN_BAND_RISE = 0.05f
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
/** Estimates above this are implausible and suppressed. */
const val MAX_MINUTES = 24 * 60
/**
* Smoothing for the displayed minutes. Asymmetric on purpose: react quickly when the estimate
* DROPS (less time left — e.g. a degraded battery measured draining faster than its rating, or a
* first live fit undercutting the spec seed) so we don't keep showing more life than the latest
* reading supports, but ease UP slowly to swallow upward noise and stay conservative.
*/
const val MINUTES_ALPHA_DOWN = 0.6f
const val MINUTES_ALPHA_UP = 0.25f
/** Smoothing for the persisted per-mode learned rate. */
const val LEARN_ALPHA = 0.3f
/**
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
*/
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
val recent = recentWindow(samples, MIN_SAMPLES) ?: return null
if (recent.first().fraction - recent.last().fraction < MIN_TOTAL_DROP) return null
// Negative slope == draining; flip to a positive drain rate.
val rate = regressionSlopePerHour(recent)?.let { -it } ?: return null
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
return rate
}
/**
* Least-squares slope of RISING [samples] (a charging pod) as a positive charge rate in
* fraction/hour, with the same window guards as the drain fit but charge-tuned thresholds.
*/
fun chargeSlopeFractionPerHour(samples: List<DrainSample>): Float? {
val recent = recentWindow(samples, MIN_SAMPLES_CHARGE) ?: return null
if (recent.last().fraction - recent.first().fraction < MIN_TOTAL_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
if (!rate.isFinite() || rate < CHARGE_RATE_MIN || rate > CHARGE_RATE_MAX) return null
return rate
}
/**
* The regimes of a lithium CC/CV charge. Constant-current bulk is fast and roughly linear;
* once the cell hits its voltage ceiling the charger switches to constant-voltage and the
* current decays, ending in a slow trickle. One linear rate over-promises badly above ~80%,
* so each band learns its own rate.
*
* Band boundaries and seeds are grounded rather than guessed:
* - The 80% bulk boundary is Apple's own documented behavior for their devices ("uses fast
* charging to quickly reach 80% ... then switches to slower trickle charging",
* apple.com/batteries/why-lithium-ion, mirrored in the iPhone user guide).
* - Battery-charging literature places the CC->CV transition at ~75-85% SoC (lower at high
* charge currents) and describes the CV current as decaying until cutoff, with the
* saturation stage taking a large share of total charge time (Battery University BU-409,
* "Charging Lithium-ion").
* - Two CV bands, not more: the CV current decays continuously, so finer bands would model
* noise while multiplying the state to learn. With the 0.5/0.3 seeds below, the last 20%
* takes ~40% of the seeded total charge time — inside BU-409's reported envelope.
*
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
* bands from them needs the CV haircut. Applied ONLY to the spec seed — measured or learned
* rates already reflect where they were observed.
*/
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
BULK(0.0f, 0.8f, 1.0f),
TAPER(0.8f, 0.9f, 0.5f),
TRICKLE(0.9f, 1.0f, 0.3f),
}
/**
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
* the wide bulk band keeps the regular sample requirement.
*/
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
val recent = samples.filter {
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
}
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
val floor = CHARGE_RATE_MIN * band.specMultiplier
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
}
/**
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
* usable rate, or the result is implausible.
*/
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
if (!levelFraction.isFinite() || levelFraction < 0f) return null
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
var totalMinutes = 0.0
for (band in ChargeBand.entries) {
val start = maxOf(levelFraction, band.from)
val missing = band.to - start
if (missing <= 0f) continue
val rate = rateForBand(band) ?: return null
if (rate <= 0f) return null
totalMinutes += missing / rate * 60.0
}
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
}
/**
* How long the level may sit unchanged while charging before the "until charged" estimate is
* considered stalled (Optimized Battery Charging hold, trickle phase, or a dead reading) and
* suppressed. Granularity-aware: at [chargeFractionPerHour] a single visible step of
* [stepFraction] (1% on AAP, 10% on BLE) takes `step/rate` hours — a slow BLE charge legitimately
* shows no change for ~20 minutes, so a fixed timeout would falsely suppress it.
*/
fun chargeStallThresholdMs(chargeFractionPerHour: Float, stepFraction: Float): Long {
if (chargeFractionPerHour <= 0f) return CHARGE_STALL_FLOOR_MS
val stepMs = (stepFraction.toDouble() / chargeFractionPerHour * 3_600_000).roundToLong()
return maxOf(CHARGE_STALL_FLOOR_MS, stepMs * 3 / 2)
}
/** Samples within [MAX_SAMPLE_AGE_MS] of the newest, or null when count/span guards fail. */
private fun recentWindow(samples: List<DrainSample>, minSamples: Int): List<DrainSample>? {
if (samples.size < minSamples) return null
// Restrict to the recent window so a gap before the newest sample can't span the fit.
val newestMs = samples.last().atElapsedMs
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
return recent
}
/** Signed least-squares slope (fraction per hour) over [recent]; negative when draining. */
private fun regressionSlopePerHour(recent: List<DrainSample>): Float? {
val first = recent.first()
val n = recent.size.toDouble()
var sumX = 0.0
var sumY = 0.0
var sumXY = 0.0
var sumXX = 0.0
for (s in recent) {
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
val y = s.fraction.toDouble()
sumX += x
sumY += y
sumXY += x * y
sumXX += x * x
}
val denominator = n * sumXX - sumX * sumX
if (abs(denominator) < 1e-9) return null
return ((n * sumXY - sumX * sumY) / denominator).toFloat()
}
/**
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
*/
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
return minutes.takeIf { it in 1..MAX_MINUTES }
}
/**
* Exponential moving average over the displayed minutes, to avoid a jumpy number. Uses the
* asymmetric [MINUTES_ALPHA_DOWN]/[MINUTES_ALPHA_UP] factors by default; pass an explicit [alpha]
* to force a symmetric factor (used by tests).
*/
fun blendMinutes(previous: Int?, next: Int, alpha: Float? = null): Int {
if (previous == null) return next
val a = alpha ?: if (next < previous) MINUTES_ALPHA_DOWN else MINUTES_ALPHA_UP
return (next * a + previous * (1f - a)).roundToInt()
}
/** Exponential moving average over the persisted learned rate across sessions. */
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
if (previous == null) next else next * alpha + previous * (1f - alpha)
}
@@ -0,0 +1,70 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.pods.core.apple.PodModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
*
* [chargeRates] is the charging counterpart, keyed per slot only (`"LEFT"` / `"RIGHT"` /
* `"HEADSET"`) — the ANC mode is irrelevant while a pod sits in the case.
*
* [model] tags which [PodModel] the rates were learned on, so a profile re-assigned to different
* hardware doesn't inherit the old device's rates (see [matchesModel]).
*/
@Serializable
data class DrainProfile(
@SerialName("model") val model: String? = null,
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
/**
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
*/
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
/**
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
* figures, so the battery-health estimate compares against these; the general [rates]
* (which include idle wear) keep powering the time-remaining estimate.
*/
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
) {
@Serializable
data class LearnedRate(
/** Drain (or charge) rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
@SerialName("fractionPerHour") val fractionPerHour: Float,
@SerialName("sampleCount") val sampleCount: Int,
/**
* How many distinct sessions have blended into this rate. [sampleCount] is only the window
* size at the LAST save, so this is the actual accumulated-evidence signal (used e.g. to
* gate the derived battery-health figure).
*/
@SerialName("updateCount") val updateCount: Int = 1,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
/**
* Whether these learned rates apply to [model]. An untagged profile or an UNKNOWN model on
* either side is treated as matching — only a definite known-A vs known-B mismatch (the user
* re-pointed the profile at different hardware) disqualifies the data.
*/
fun matchesModel(model: PodModel): Boolean =
this.model == null ||
model == PodModel.UNKNOWN ||
this.model == PodModel.UNKNOWN.name ||
this.model == model.name
companion object {
/** Bucket key for rates learned while the ANC mode wasn't known (BLE-only sessions). */
const val BUCKET_UNKNOWN = "UNKNOWN"
}
}
@@ -8,7 +8,6 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
@@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -52,7 +52,6 @@ class BlePodMonitor @Inject constructor(
private val timeSource: TimeSource,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -60,6 +59,62 @@ class BlePodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
/**
* Drops all cached observations. The troubleshooter calls this between probe attempts so a
* previous compat combo's cached devices (kept up to [STALE_DEVICE_TIMEOUT]) can't leak into
* the next attempt and falsely satisfy it — including via [preferCaseContextPod], which would
* otherwise hand back a stale snapshot whose timestamp predates the fresh scan.
*/
suspend fun clearDeviceCache() = cacheLock.withLock {
log(TAG) { "clearDeviceCache()" }
deviceCache.clear()
}
/**
* Ephemeral override that disables the proximity-pairing scan filter so
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
* every process start; the troubleshooter is the only writer.
*/
private val unfilteredOverride = MutableStateFlow(false)
fun setUnfilteredOverride(enabled: Boolean) {
unfilteredOverride.value = enabled
}
/**
* Ephemeral override for the three BLE compatibility options. The troubleshooter uses this to
* probe combinations without writing the user's persisted settings: while set, it fully replaces
* the persisted compat values for the active scan. Resets to null on every process start; the
* troubleshooter is the only writer and always clears it when finished, so clearing it restores
* the user's original settings for free.
*/
private val compatOverride = MutableStateFlow<CompatOverride?>(null)
fun setCompatOverride(override: CompatOverride?) {
log(TAG) { "setCompatOverride($override)" }
compatOverride.value = override
}
data class CompatOverride(
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val indirectCallback: Boolean,
)
/** Persisted compat settings, transparently replaced by [compatOverride] while it is set. */
private val effectiveCompat: Flow<CompatOverride> = combine(
compatOverride,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) { override, filteringDisabled, batchingDisabled, indirectCallback ->
override ?: CompatOverride(
offloadedFilteringDisabled = filteringDisabled,
offloadedBatchingDisabled = batchingDisabled,
indirectCallback = indirectCallback,
)
}
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
@@ -134,23 +189,15 @@ class BlePodMonitor @Inject constructor(
private fun createBleScanner() = combine(
bleScanModeController.scannerMode,
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
unfilteredOverride,
effectiveCompat,
) { scannermode, showUnfiltered, compat ->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
disableDirectCallback = compat.indirectCallback,
)
}
.throttleLatest(1000)
@@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -30,9 +29,13 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -42,6 +45,9 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.conversation.ConversationReaction
import eu.darken.capod.reaction.core.charged.ChargedReaction
import eu.darken.capod.reaction.core.charged.ChargedReactionNotifications
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
@@ -78,9 +84,14 @@ class MonitorService : Service() {
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var chargedReaction: ChargedReaction
@Inject lateinit var chargedReactionNotifications: ChargedReactionNotifications
@Inject lateinit var conversationReaction: ConversationReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver
@Inject lateinit var batteryEstimator: BatteryEstimator
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -88,6 +99,10 @@ class MonitorService : Service() {
private var foregroundStartFailed = false
private var injectionComplete = false
@Volatile
private var latestNotificationSettings: NotificationSettings =
NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false)
@SuppressLint("InlinedApi")
private fun promoteToForeground(notification: Notification): Boolean {
return try {
@@ -188,27 +203,71 @@ class MonitorService : Service() {
}
private suspend fun doMonitor() {
// Seed snapshot eagerly so onDestroy() has correct values even if we abort below.
latestNotificationSettings = NotificationSettings(
useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(),
keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(),
)
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
return
}
val monitorJob = deviceMonitor.primaryDevice()
val deviceFlow = deviceMonitor.primaryDeviceByTier
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() }
.throttleLatest(1000)
.onEach { currentDevice ->
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
val notificationSettingsFlow = combine(
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { useExtra, keepAfter ->
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
}
val monitorJob = combine(
deviceFlow,
notificationSettingsFlow,
batteryEstimator.estimates,
) { currentDevice, settings, estimates ->
NotificationInput(
device = currentDevice,
settings = settings,
estimate = currentDevice?.let { estimates.estimateFor(it) },
)
}
// The estimates map churns on fields the notification doesn't display (and for other
// profiles' devices) — only re-notify when something visible changed.
.distinctUntilChangedBy { input ->
Triple(
input.device?.toNotificationKey(),
input.settings,
input.device?.let { input.estimate?.displayKey(it) },
)
}
.onEach { (currentDevice, settings, estimate) ->
latestNotificationSettings = settings
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
notifications.getNotification(currentDevice, showHint = useExtraNotification),
notifications.getNotification(
currentDevice,
estimate = estimate,
showHint = settings.useExtraNotification,
),
)
if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) {
notificationManager.notify(
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
is ExtraNotificationAction.Post -> notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
notifications.getNotificationConnected(currentDevice),
notifications.getNotificationConnected(action.device, estimate),
)
ExtraNotificationAction.Cancel -> notificationManager.cancel(
MonitorNotifications.NOTIFICATION_ID_CONNECTED
)
ExtraNotificationAction.KeepExisting -> Unit
}
}
.catch {
@@ -224,7 +283,7 @@ class MonitorService : Service() {
emptyFlow()
} else {
combine(
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
bluetoothManager.connectedDevices,
aapConnectionManager.allStates,
@@ -300,6 +359,33 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
chargedReaction.monitor()
.onEach { event ->
when (event) {
is ChargedReaction.Event.ShowNotification -> chargedReactionNotifications.show(
profileId = event.profileId,
deviceLabel = event.deviceLabel,
thresholdPercent = event.thresholdPercent,
)
is ChargedReaction.Event.CancelNotification ->
chargedReactionNotifications.cancel(event.profileId)
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
.catch { log(TAG, WARN) { "chargedReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
conversationReaction.monitor()
.setupCommonEventHandlers(TAG) { "conversationReaction" }
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
batteryEstimator.monitor()
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -310,7 +396,13 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed")
if (injectionComplete) {
if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) {
try {
chargedReactionNotifications.cancelAll()
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel charged notifications: ${e.message}" }
}
val snapshot = latestNotificationSettings
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
} catch (e: Exception) {
@@ -359,10 +451,17 @@ internal fun buildMonitorModeState(
hasAapSession = aapStates.isNotEmpty(),
)
internal data class NotificationInput(
val device: PodDevice?,
val settings: NotificationSettings,
val estimate: BatteryEstimate?,
)
private data class NotificationDeviceKey(
val profileId: String?,
val label: String?,
val model: PodModel,
val isLive: Boolean,
val hasDualPods: Boolean,
val hasCase: Boolean,
val hasEarDetection: Boolean,
@@ -377,6 +476,7 @@ private data class NotificationDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val iconRes: Int,
val leftPodIcon: Int,
val rightPodIcon: Int,
@@ -387,6 +487,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
profileId = profileId,
label = label,
model = model,
isLive = isLive,
hasDualPods = hasDualPods,
hasCase = hasCase,
hasEarDetection = hasEarDetection,
@@ -401,8 +502,30 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
iconRes = iconRes,
leftPodIcon = leftPodIcon,
rightPodIcon = rightPodIcon,
caseIcon = caseIcon,
)
internal data class NotificationSettings(
val useExtraNotification: Boolean,
val keepAfterDisconnect: Boolean,
)
internal sealed interface ExtraNotificationAction {
object Cancel : ExtraNotificationAction
data class Post(val device: PodDevice) : ExtraNotificationAction
object KeepExisting : ExtraNotificationAction
}
internal fun decideExtraNotificationAction(
device: PodDevice?,
settings: NotificationSettings,
): ExtraNotificationAction = when {
!settings.useExtraNotification -> ExtraNotificationAction.Cancel
device != null && device.isLive -> ExtraNotificationAction.Post(device)
settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting
else -> ExtraNotificationAction.Cancel
}
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@@ -68,12 +71,10 @@ class MonitorNotificationViewFactory @Inject constructor(
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
@@ -83,13 +84,13 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
fun createBigContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device, estimate)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
else -> createUnknownDeviceBig(device)
}
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_big
).apply {
@@ -97,7 +98,11 @@ class MonitorNotificationViewFactory @Inject constructor(
val leftPercent = device.batteryLeft
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
setTextViewText(
R.id.pod_left_label,
formatBatteryPercent(context, leftPercent) +
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
)
val isLeftPodCharging = device.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = device.isLeftInEar ?: false
@@ -117,14 +122,18 @@ class MonitorNotificationViewFactory @Inject constructor(
val rightPercent = device.batteryRight
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
setTextViewText(
R.id.pod_right_label,
formatBatteryPercent(context, rightPercent) +
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
)
val isRightPodCharging = device.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = device.isRightInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_big
).apply {
@@ -132,16 +141,18 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
setTextViewText(
R.id.headphones_battery_label,
formatBatteryPercent(context, headsetPercent) +
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
)
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
@@ -154,4 +165,7 @@ class MonitorNotificationViewFactory @Inject constructor(
private fun percentToInt(percent: Float): Int =
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
}
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import javax.inject.Inject
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
private fun getBuilder(
device: PodDevice?,
channelId: String,
showHint: Boolean = false
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): NotificationCompat.Builder {
if (device == null) {
return baseBuilder(channelId).apply {
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomContentView(notificationViewFactory.createContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
setContentTitle("$batteryText ~ $stateText")
setSubText(null)
log(TAG, VERBOSE) { "updatingNotification(): $device" }
}
}
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
fun getNotification(
podDevice: PodDevice?,
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
fun getNotificationConnected(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
fun getStartupNotification(): Notification =
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
@@ -14,6 +14,7 @@ enum class PodModel(
@DrawableRes val leftPodIconRes: Int? = null,
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
val batterySpec: BatterySpec? = null,
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
@@ -26,6 +27,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -43,6 +45,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -64,6 +67,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -86,6 +90,7 @@ enum class PodModel(
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -117,6 +122,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -142,6 +148,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -175,6 +182,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -208,6 +216,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -242,6 +251,7 @@ enum class PodModel(
hasSleepDetection = true,
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -262,6 +272,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A2096"), // headphones
),
@@ -279,6 +290,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3184"), // headphones
),
@@ -300,6 +312,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3454"), // headphones
),
@@ -580,4 +593,27 @@ enum class PodModel(
*/
val hasDynamicEndOfCharge: Boolean = false,
)
/**
* Apple's published single-charge listening-time ratings, in hours, used to seed the battery
* time-remaining estimate before observed drain is available and as an upper bound on it. These
* are the POD-ONLY listening figures from Apple's tech-spec pages — NOT the "with charging case"
* aggregate. Left null for models Apple doesn't publish figures for (Beats, unknown), which simply
* fall back to purely observed drain. Figures verified against apple.com tech specs (2026-07).
*/
data class BatterySpec(
/** Listening hours with Active Noise Cancellation / Transparency active. */
val listeningHoursAncOn: Float? = null,
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
val listeningHoursAncOff: Float? = null,
/**
* Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's
* published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of
* listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two
* listening ratings it's normalized against the LONGER one, so the seeded "until charged"
* never under-promises. Seeds the charge ETA on the very first charge, before any charge
* rate has been measured; a live fit takes over within minutes.
*/
val chargeFractionPerHour: Float? = null,
)
}
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -53,8 +54,8 @@ class AapConnectionManager @Inject constructor(
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
private val _disconnectEvents = MutableSharedFlow<AapDisconnectEvent>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<AapDisconnectEvent> = _disconnectEvents.asSharedFlow()
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/**
* Emits when a connected device reports a Conversational Awareness transition (START / RESUME /
* HOLD / STOP, AAP command 0x4B). Paired with the origin address so the conversation reaction
* can gate on the primary device. Every well-formed frame is classified and emitted (unknown
* status bytes map to HOLD); only structurally malformed frames are dropped (see [AapSessionEngine]).
*/
private val _conversationalAwarenessEvents =
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
_conversationalAwarenessEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
}
}
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
launch {
connection.conversationalAwarenessEvents.collect { event ->
_conversationalAwarenessEvents.tryEmit(address to event)
}
}
// Forward OFF-rejection events from this connection (child coroutine)
launch {
connection.offRejected.collect {
@@ -169,7 +188,7 @@ class AapConnectionManager @Inject constructor(
}
if (!wasIntentional) {
_disconnectEvents.tryEmit(address)
_disconnectEvents.tryEmit(AapDisconnectEvent(address, connection.wasEverReady))
}
// End this collector coroutine (also cancels child key-forwarding coroutine)
@@ -198,3 +217,13 @@ class AapConnectionManager @Inject constructor(
connection.send(command)
}
}
/**
* Emitted on an unintentional AAP disconnect. [wasEverReady] tells the reconnect layer whether this
* session ever completed the handshake (reached READY) — a never-ready disconnect is a failed
* handshake/short session and feeds the escalating reconnect backoff; a was-ready drop reconnects promptly.
*/
data class AapDisconnectEvent(
val address: BluetoothAddress,
val wasEverReady: Boolean,
)
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CompletableDeferred
@@ -22,12 +23,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
@@ -45,19 +48,25 @@ internal class AapConnection(
private val socketFactory: L2capSocketFactory,
timeSource: TimeSource,
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
private val handshakeTimeout: Duration = DEFAULT_HANDSHAKE_TIMEOUT,
) {
private val engine = AapSessionEngine(profile, timeSource)
/** True once this connection's session reached READY at least once. Survives the engine reset. */
val wasEverReady: Boolean get() = engine.wasEverReady
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
private val disconnected = AtomicBoolean(false)
private val writeMutex = Mutex()
private val framer = AapFramer()
@@ -107,6 +116,27 @@ internal class AapConnection(
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
// Handshake watchdog: the socket can connect and the handshake be sent, but if the peer
// never replies the engine sits in HANDSHAKING forever (blocking read, no deadline).
// Bound it: if neither READY nor DISCONNECTED is reached in time, tear the socket down so
// the reconnect path can recover. READY / an earlier DISCONNECTED end the wait with no action.
scope.launch {
val settled = withTimeoutOrNull(handshakeTimeout) {
state.first {
it.connectionState == AapPodState.ConnectionState.READY ||
it.connectionState == AapPodState.ConnectionState.DISCONNECTED
}
}
// Re-check after the timeout: READY may have landed in the boundary race between
// withTimeoutOrNull cancelling and us getting here — don't tear down a live session.
if (settled == null && state.value.connectionState != AapPodState.ConnectionState.READY) {
log(TAG, Logging.Priority.WARN) {
"Handshake timed out after $handshakeTimeout for ${device.address} — disconnecting"
}
disconnect()
}
}
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
cleanupSocket()
@@ -116,6 +146,9 @@ internal class AapConnection(
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
// Idempotent: the handshake watchdog and the manager's DISCONNECTED observer can both reach
// here for the same dying session. Run the teardown exactly once.
if (!disconnected.compareAndSet(false, true)) return@withContext
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
@@ -259,6 +292,13 @@ internal class AapConnection(
companion object {
private const val PSM = 0x1001
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
/**
* Upper bound on the post-handshake wait for the first sign of life (READY). Normal
* handshakes complete in well under 2s; 10s tolerates a congested 2.4 GHz band before
* giving up so the reconnect path can recover instead of wedging in HANDSHAKING forever.
*/
internal val DEFAULT_HANDSHAKE_TIMEOUT = 10.seconds
private val TAG = logTag("AAP", "Connection")
}
}
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
private val _conversationalAwarenessEvents =
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
_conversationalAwarenessEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
@@ -80,6 +86,15 @@ internal class AapSessionEngine(
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
private var runtimeState = EngineRuntimeState()
/**
* True once this session has reached [AapPodState.ConnectionState.READY] at least once.
* Deliberately NOT cleared by [reset] — consumers read it at disconnect time to tell a
* working session that dropped apart from one that never completed the handshake (drives
* the reconnect backoff). Cleared only when a fresh session starts.
*/
private var _wasEverReady: Boolean = false
val wasEverReady: Boolean get() = _wasEverReady
fun start(scope: CoroutineScope) {
dispatch(AapEngineEvent.SessionStarted(scope))
}
@@ -124,6 +139,7 @@ internal class AapSessionEngine(
when (event) {
is AapEngineEvent.SessionStarted -> {
scope = event.scope
_wasEverReady = false
runtimeState = runtimeState.copy(handshakeResponseReceived = false)
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
}
@@ -153,11 +169,14 @@ internal class AapSessionEngine(
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
if (packet.status != 0) {
log(TAG, ERROR) {
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
"ConnectResponse rejected (status=0x${"%04X".format(packet.status)}) — tearing down: " +
"major=${packet.major} minor=${packet.minor} " +
"features=0x${"%016X".format(packet.features.toLong())}"
}
_state.value = _state.value.copy(connectResponseStatus = packet.status)
// Hard protocol rejection: don't wait out the handshake watchdog — disconnect now so
// the reconnect path can recover (or back off). Nested dispatch mirrors the existing
// inbound-update re-dispatch pattern.
dispatch(AapEngineEvent.ResetRequested)
return
}
@@ -186,6 +205,7 @@ internal class AapSessionEngine(
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
) {
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
_wasEverReady = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
@@ -301,6 +321,13 @@ internal class AapSessionEngine(
)
}
}
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
// conversation reaction: START / RESUME / HOLD / STOP. The decoder already dropped malformed
// frames (rawValue stays null only in that case). The raw payload remains logged.
if (value is AapSetting.ConversationalAwarenessState) {
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
}
}
private fun handleTimerFired(key: EngineTimerKey) {
@@ -99,9 +99,18 @@ sealed class AapSetting {
val level: Int,
) : AapSetting()
/** Push-only from device — reports speaking detection state (command 0x4B). */
/**
* Push-only from device — reports speaking detection state (command 0x4B).
*
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` while
* the wearer is talking — onset (`1`, `2`) or resumed after a pause (`5`); every other value
* (`0`, `3`, `4`, `0x0B`, …) is `false`. START/RESUME/HOLD/STOP classification for the reaction
* lives in [ConversationAwarenessEvent].
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
val rawValue: Int? = null,
) : AapSetting()
data class MicrophoneMode(
@@ -0,0 +1,50 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
*
* Status-byte mapping, derived from a labelled capture set across AirPods Pro 3 and Pro 2 USB-C
* (`protocol-research/conversationalawareness/` — both models share one firmware train and emit
* byte-identical sequences). Each scenario was captured with a known action (normal talking,
* bursty talking, single-pod, volume-up abort, pod removal/case):
* - `1`, `2` → [START] (conversation onset / wind-up → engage the reaction). A conversation always
* opens `1` then `2`; re-onset only ever happens after a terminal.
* - `5` → [RESUME] (speech resumed after a pause; the wind-down was aborted, CA stays engaged). In
* bursty speech the pod cycles `3,5,3,5,…`; `5` is NEVER a terminal. Misreading `5` as a stop was
* the root cause of the premature-resume bug — see [ConversationReaction].
* - `6`, `8`, `9` → [STOP] (conversation ended → disengage). The usual terminal is the `8`→`9`
* pair; `6` is a standalone terminal seen live on Pro 2 USB-C fw `…6814`. Pod removal / case-close
* also emit `8`,`9` (sometimes with no prior `1`,`2`).
* - any other value (`3` pause, `4` / `0x0B` wind-down, `7` abort, and anything unrecognised)
* → [HOLD]: a transitional "possible/real wind-down" frame. The real wind-down runs `3→0x0B→4`
* then the `8,9` terminal; `7` precedes an aborted terminal. Unknown values are deliberately
* classified as HOLD (arm the safety fuse, never resume immediately) rather than guessed at.
*
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
* A HOLD means the wind-down may have begun; with only ONE pod worn the `8,9` terminal is
* deterministically dropped — the flurry ends on `4` (#608, reproduced on Pro 3 and Pro 2) — so
* [ConversationReaction] treats a HOLD as "terminal imminent" and arms a short fuse, with a long
* stale backstop for a fully-dropped flurry. A RESUME (`5`) cancels that fuse and re-arms the long
* backstop; trailing frames after a terminal are ignored once disengaged.
*/
enum class ConversationAwarenessEvent {
START,
RESUME,
HOLD,
STOP,
;
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val RESUME_STATUSES = setOf(5)
val STOPPED_STATUSES = setOf(6, 8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
in RESUME_STATUSES -> RESUME
in STOPPED_STATUSES -> STOP
else -> HOLD
}
}
}
@@ -183,12 +183,27 @@ class DefaultAapDeviceProfile(
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
}
// Conversation Awareness State is a separate command type (push-only)
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
// The status (speaking marker) is the last byte in both — but we validate the shape rather
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
val p = message.payload
val status = when {
p.size == 1 -> p[0].toInt() and 0xFF
p.size == 4 &&
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
p[3].toInt() and 0xFF
else -> return null
}
// speaking = the wearer is currently speaking: onset (1,2) or resumed after a pause (5).
// See ConversationAwarenessEvent. (Consumers read rawValue for full classification; this
// bool just exposes "is talking now", so a resume must count as speaking too.)
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES ||
status in ConversationAwarenessEvent.RESUME_STATUSES
return AapSetting.ConversationalAwarenessState::class to
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
}
if (message.commandType != AapMessageType.CONTROL.value) return null
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
else context.getString(R.string.general_value_not_available_label)
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return when {
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
}
}
@DrawableRes
fun getBatteryDrawable(percent: Float): Int = when {
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
@@ -4,6 +4,14 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import java.time.Duration
/**
* How far back to look when recovering the lid state from a recent in-case-pod broadcast.
* Time-bounded rather than count-bounded: BLE scan batching means a fixed number of frames is not a
* stable time window, and an old reading must not be allowed to resurrect a stale OPEN/CLOSED.
*/
private val MAX_LID_RECOVERY_AGE: Duration = Duration.ofSeconds(2)
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
// A pod broadcasting from inside the case has authoritative case state
// A pod broadcasting from inside the case has authoritative case state. Out-of-case frames
// (one pod removed) report UNKNOWN via DualApplePods.caseLidState and are skipped here.
if (basic.hasCaseContext && basic.caseLidState in setOf(
DualApplePods.LidState.OPEN,
DualApplePods.LidState.CLOSED,
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
return basic.caseLidState
}
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN.
// Check recent history for a sibling broadcast that has case context.
val fromCaseContext = history
.takeLast(4)
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
// stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
// bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
val recentHistory = history
.filterIsInstance<DualApplePods>()
.filter { Duration.between(it.seenLastAt, basic.seenLastAt).abs() <= MAX_LID_RECOVERY_AGE }
val fromCaseContext = recentHistory
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
// No case-context broadcast in recent history — current value is best we have
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
// Last resort: any non-UNKNOWN from history
return history
.takeLast(2)
.filterIsInstance<DualApplePods>()
// Last resort: any non-UNKNOWN from recent history
return recentHistory
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
val caseLidState: LidState
get() = LidState.fromRaw(pubCaseLidState, hasCaseContext)
get() = LidState.fromRaw(
raw = pubCaseLidState,
hasCaseContext = hasCaseContext,
// The lid bit is only trustworthy from a pod broadcasting inside the case (bit 6), or
// while both pods are in the case (bit 2). A bit4-only frame comes from the out-of-case
// pod and carries a stale lid byte (see LidState.fromRaw).
lidReadingReliable = isThisPodInThecase || areBothPodsInCase,
)
/**
* TODO this is glitchy
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
UNKNOWN;
companion object {
fun fromRaw(raw: UByte, hasCaseContext: Boolean): LidState {
/**
* Derives the lid state from the raw lid byte.
*
* The open/closed bit is only meaningful when broadcast by a pod that is itself inside
* the case ([isThisPodInThecase]) or while both pods are in the case ([areBothPodsInCase]).
* When only one pod is in the case and the *other*, out-of-case pod is the one
* broadcasting (bit4-only), its lid byte is stale and decodes to a phantom OPEN even while
* the case is physically shut (verified on AirPods Pro 1 & Pro 3). Such frames must report
* [UNKNOWN] so they don't drive case-open reactions; consumers recover the real state from
* an in-case-pod broadcast instead.
*/
fun fromRaw(raw: UByte, hasCaseContext: Boolean, lidReadingReliable: Boolean): LidState {
if (!hasCaseContext) return NOT_IN_CASE
if (!lidReadingReliable) return UNKNOWN
return when ((raw.toInt() shr 3) and 0x01) {
0 -> OPEN
@@ -16,6 +16,12 @@ data class KnownDevice(
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
/**
* Profile id this device has ever IRK-resolved to, or null if it has only ever been seen as a
* keyless device. Durable — kept even when [history] is trimmed — so the weak history-recovery
* paths can reliably refuse to hand an IRK-backed identity to a foreign same-model frame.
*/
val boundProfileId: String? = null,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
@@ -76,7 +76,8 @@ class PodHistoryRepo @Inject constructor(
val caseIgnored = if (current is DualApplePods) {
val target = current.getCaseMatchMarkings()
knownDevices.values.filter { known ->
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target } &&
known.isIrkConsistentWith(current)
}
} else {
emptySet()
@@ -143,7 +144,7 @@ class PodHistoryRepo @Inject constructor(
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers && it.isIrkConsistentWith(current) }
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
}
@@ -154,6 +155,24 @@ class PodHistoryRepo @Inject constructor(
return recognizedDevice
}
/**
* The weak history paths (fuzzy markers, case-ignored markings) match on low-entropy broadcast
* data — model + battery nibbles + colour — which a stranger's same-model AirPods at a similar
* battery satisfies. Those paths must not let a foreign frame inherit the identity of one of OUR
* keyed (IRK-backed) devices, or the foreign snapshot poisons that device's cache slot and the
* dashboard card glitches (Symptom B).
*
* A [KnownDevice] is "IRK-backed" if it has ever resolved against a profile's IRK
* ([KnownDevice.boundProfileId], latched durably so history trimming can't erode it). Such a
* device may only be claimed via the strong address/IRK paths, or by a current frame that
* resolved to the SAME profile. Keyless devices (never IRK-backed) are unaffected — fuzzy
* recovery still works for them exactly as before.
*/
private fun KnownDevice.isIrkConsistentWith(current: ApplePods): Boolean {
val bound = boundProfileId ?: return true // not IRK-backed -> keyless, weak match allowed
return current.meta.profile?.id == bound
}
fun updateHistory(device: ApplePods) {
val existing = knownDevices[device.identifier]
@@ -162,12 +181,16 @@ class PodHistoryRepo @Inject constructor(
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
// Latch the IRK-resolved profile id (never cleared by an unbound frame), so it survives history trimming.
val boundProfileId = device.meta.profile?.id?.takeIf { device.meta.isIRKMatch }
knownDevices[device.identifier] = if (existing != null) {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery,
boundProfileId = boundProfileId ?: existing.boundProfileId,
)
} else {
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
@@ -177,7 +200,8 @@ class PodHistoryRepo @Inject constructor(
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
lastCaseBattery = history.determineLatestCaseBattery(),
boundProfileId = boundProfileId,
)
}
}
@@ -2,7 +2,9 @@ package eu.darken.capod.pods.core.apple.ble.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
@@ -16,6 +18,17 @@ object ProximityPairing {
return null
}
// Pods also emit type 0x07 frames whose payload does not use the plaintext status
// format, e.g. prefix 0x07 from the identity address on connect (#603). Their bytes
// are garbage at the standard offsets and would create a ghost device.
if (message.data.firstOrNull() != PAIRING_MESSAGE_PREFIX) {
log(TAG, DEBUG) {
val hex = message.data.joinToString(" ") { "%02X".format(it.toInt()) }
"Dropping proximity pairing message with unsupported prefix: [$hex]"
}
return null
}
return ProximityMessage(
type = message.type,
length = message.length,
@@ -49,4 +62,9 @@ object ProximityPairing {
// This is the default message length among official Apple devices, clones may have different length
internal const val PAIRING_MESSAGE_LENGTH = 25
// First payload byte of every known plaintext status broadcast, including pairing mode
internal val PAIRING_MESSAGE_PREFIX = 0x01.toUByte()
private val TAG = logTag("Pod", "Apple", "ProximityPairing")
}
@@ -5,6 +5,8 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
@@ -25,11 +27,19 @@ data class AppleDeviceProfile(
@SerialName("address") override val address: String? = null,
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
/** Whether the dashboard battery time-remaining estimate is shown for this device. */
@SerialName("batteryEstimateEnabled") val batteryEstimateEnabled: Boolean = true,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -49,11 +59,17 @@ data class AppleDeviceProfile(
get() = ReactionConfig(
autoPause = autoPause,
autoPlay = autoPlay,
startMusicOnWear = startMusicOnWear,
onePodMode = onePodMode,
autoConnect = autoConnect,
autoConnectCondition = autoConnectCondition,
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
showPopUpOnConnection = showPopUpOnConnection,
conversationAction = conversationAction,
conversationVolumeReduction = conversationVolumeReduction,
notifyWhenCharged = notifyWhenCharged,
chargedThreshold = chargedThreshold,
chargedSlotScope = chargedSlotScope,
)
override fun toString(): String = "AppleDeviceProfile(" +
@@ -62,10 +78,17 @@ data class AppleDeviceProfile(
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
"startMusicOnWear=$startMusicOnWear, " +
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
"autoConnectCondition=$autoConnectCondition, " +
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
"showPopUpOnConnection=$showPopUpOnConnection, " +
"conversationAction=$conversationAction, " +
"conversationVolumeReduction=$conversationVolumeReduction, " +
"notifyWhenCharged=$notifyWhenCharged, " +
"chargedThreshold=$chargedThreshold, " +
"chargedSlotScope=$chargedSlotScope, " +
"batteryEstimateEnabled=$batteryEstimateEnabled, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
@@ -11,6 +11,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.cache.DeviceStateCache
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import kotlinx.serialization.json.Json
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings,
private val deviceStateCache: DeviceStateCache,
private val batteryDrainStore: BatteryDrainStore,
@SerializationCapod private val json: Json,
) {
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" }
deviceStateCache.delete(profileId)
batteryDrainStore.delete(profileId)
}
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
suspend fun clear() = mutex.withLock {
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
deviceStateCache.deleteAll()
batteryDrainStore.deleteAll()
}
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
@@ -1,16 +1,39 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
data class ReactionConfig(
val autoPause: Boolean = false,
val autoPlay: Boolean = false,
val startMusicOnWear: Boolean = false,
val onePodMode: Boolean = false,
val autoConnect: Boolean = false,
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
val showPopUpOnCaseOpen: Boolean = false,
val showPopUpOnConnection: Boolean = false,
)
val conversationAction: ConversationAction = ConversationAction.NOTHING,
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
val notifyWhenCharged: Boolean = false,
/** Battery percentage at which the charged notification fires (clamped 50..100 on use). */
val chargedThreshold: Int = DEFAULT_CHARGED_THRESHOLD,
val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
) {
companion object {
const val DEFAULT_CHARGED_THRESHOLD = 100
const val MIN_CHARGED_THRESHOLD = 50
const val MAX_CHARGED_THRESHOLD = 100
const val CHARGED_THRESHOLD_STEP = 10
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
}
}
interface HasReactionConfig {
val reactionConfig: ReactionConfig
@@ -1,6 +1,8 @@
package eu.darken.capod.reaction.core.autoconnect
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -26,6 +28,7 @@ import javax.inject.Singleton
class AutoConnect @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val deviceMonitor: DeviceMonitor,
private val nudgeCapabilityStore: NudgeCapabilityStore,
) {
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
@@ -36,7 +39,9 @@ class AutoConnect @Inject constructor(
combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
Triple(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode)
// Include caseLidState: for the CASE_OPEN condition it is derived from history
// and can flip to OPEN while the selected frame's raw bytes are unchanged.
listOf(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode, it.caseLidState)
},
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
@@ -98,13 +103,14 @@ class AutoConnect @Inject constructor(
return@map
}
if (!bluetoothManager.isNudgeAvailable) {
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
log(TAG, WARN) { "nudgeConnection is known broken on this device, skipping" }
return@map
}
val result = bluetoothManager.nudgeConnection(bondedDevice)
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
nudgeCapabilityStore.record(result)
}
.setupCommonEventHandlers(TAG) { "monitor" }
@@ -0,0 +1,199 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.devicesWithProfiles
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReaction @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val profilesRepo: DeviceProfilesRepo,
) {
sealed class Event {
data class ShowNotification(
val profileId: String,
val deviceLabel: String,
val thresholdPercent: Int,
) : Event()
data class CancelNotification(val profileId: String) : Event()
}
private data class ChargedConfig(val scope: ChargedSlotScope, val thresholdPercent: Int)
fun monitor(): Flow<Event> = flow {
// Machines live in the collection scope: a monitor (service) restart starts fresh sessions.
val machines = mutableMapOf<String, ChargingSessionStateMachine>()
val configs = mutableMapOf<String, ChargedConfig>()
combine(
profilesRepo.profiles,
deviceMonitor.devicesWithProfiles(),
) { profiles, devices -> profiles.filterIsInstance<AppleDeviceProfile>() to devices }
.collect { (profiles, devices) ->
val enabled = profiles.filter { it.notifyWhenCharged }.associateBy { it.id }
// Profile deleted or toggle disabled → reset; a missing device snapshot is
// handled below as staleness, never as a reset.
machines.keys.filter { it !in enabled }.forEach { profileId ->
val output = machines.remove(profileId)!!.process(Input.Reset)
configs.remove(profileId)
if (output == Output.CANCEL) emit(Event.CancelNotification(profileId))
}
for (profile in enabled.values) {
val machine = machines.getOrPut(profile.id) { ChargingSessionStateMachine() }
val thresholdPercent = profile.chargedThreshold.coerceIn(
ReactionConfig.MIN_CHARGED_THRESHOLD,
ReactionConfig.MAX_CHARGED_THRESHOLD,
)
// A persisted CASE scope on a caseless model (restored backup, model change)
// would otherwise produce an eternally-empty slot set.
val scope = if (profile.model.features.hasCase) profile.chargedSlotScope else ChargedSlotScope.PODS
val config = ChargedConfig(scope, thresholdPercent)
val previousConfig = configs.put(profile.id, config)
if (previousConfig != null && previousConfig != config) {
// Scope/threshold changed: stale highwater slots and an already-shown
// notification don't fit the new settings — start evaluation over.
log(TAG, INFO) { "Settings changed for ${profile.id} ($previousConfig -> $config), resetting" }
if (machine.process(Input.Reset) == Output.CANCEL) {
emit(Event.CancelNotification(profile.id))
}
}
val device = devices.firstOrNull { it.profileId == profile.id }
val slots = device?.liveChargingSlots(scope).orEmpty()
// No live slot data (device gone, lid closed, or AAP hasn't pushed battery
// yet) is treated as staleness — the session suspends instead of resetting.
val input = if (device == null || slots.isEmpty()) {
Input.StaleUpdate
} else {
Input.LiveUpdate(
slots = slots,
threshold = thresholdPercent / 100f,
activity = device.chargingActivity(),
)
}
val phaseBefore = machine.phase
val output = machine.process(input)
if (machine.phase != phaseBefore) {
log(TAG, VERBOSE) {
"${profile.id}: $phaseBefore -> ${machine.phase} (output=$output, input=$input)"
}
}
when (output) {
Output.SHOW -> {
val label = device?.label ?: profile.label
log(TAG, INFO) { "Charge complete for ${profile.id} ($label) at $thresholdPercent%" }
emit(Event.ShowNotification(profile.id, label, thresholdPercent))
}
Output.CANCEL -> {
log(TAG, INFO) { "Charging session over for ${profile.id}, cancelling notification" }
emit(Event.CancelNotification(profile.id))
}
Output.NONE -> Unit
}
}
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
companion object {
private val TAG = logTag("Reaction", "Charged")
}
}
/**
* Per-slot live readings only — AAP first, then live BLE. The [PodDevice] facade getters are
* deliberately bypassed because they silently fall back to the disk cache; stale cached values
* must never start, complete, or end a charging session.
*/
/**
* Signals that the user is handling the device, used to dismiss an already-shown notification.
* All sources are live (ear detection: AAP/BLE; lid: BLE; DISCONNECTED slots: AAP) — the cache
* never feeds in here.
*/
internal fun PodDevice.chargingActivity(): ChargingSessionStateMachine.Activity =
ChargingSessionStateMachine.Activity(
// Only trust AAP ear detection — BLE ear bits are phantom for in-case pods (see
// PodDevice.hasAapEarDetection). Unknown when there's no AAP session, which is the
// normal state while charging in the case, so phantom "worn" never dismisses.
wornSlots = when {
!hasAapEarDetection -> null
hasDualPods -> buildSet {
if (isLeftInEar == true) add(Slot.LEFT)
if (isRightInEar == true) add(Slot.RIGHT)
}
else -> if (isBeingWorn == true) setOf(Slot.HEADSET) else emptySet()
},
lid = when (caseLidState) {
DualApplePods.LidState.OPEN -> ChargingSessionStateMachine.Lid.OPEN
DualApplePods.LidState.CLOSED -> ChargingSessionStateMachine.Lid.CLOSED
DualApplePods.LidState.NOT_IN_CASE -> ChargingSessionStateMachine.Lid.NOT_IN_CASE
else -> null
},
disconnectedSlots = buildSet {
if (leftPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.LEFT)
if (rightPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.RIGHT)
if (caseChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.CASE)
if (headsetChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.HEADSET)
},
)
internal fun PodDevice.liveChargingSlots(scope: ChargedSlotScope): Map<Slot, SlotData> {
val slots = mutableMapOf<Slot, SlotData>()
fun add(slot: Slot, battery: Float?, charging: Boolean?) {
if (battery == null || battery < 0f || charging == null) return
slots[slot] = SlotData(battery = battery, isCharging = charging)
}
if (scope != ChargedSlotScope.CASE) {
add(
Slot.LEFT,
aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent,
aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging,
)
add(
Slot.RIGHT,
aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent,
aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging,
)
add(
Slot.HEADSET,
aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent,
aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged,
)
}
if (scope != ChargedSlotScope.PODS) {
add(
Slot.CASE,
aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent,
aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging,
)
}
return slots
}
@@ -0,0 +1,87 @@
package eu.darken.capod.reaction.core.charged
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReactionNotifications @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
) {
init {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reaction_charged_channel_label),
NotificationManager.IMPORTANCE_LOW,
)
)
}
fun show(profileId: String, deviceLabel: String, thresholdPercent: Int) {
if (!notificationManager.areNotificationsEnabled()) {
log(TAG, WARN) { "Notifications disabled — charged notification suppressed" }
return
}
val openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
val text = if (thresholdPercent >= 100) {
context.getString(R.string.reaction_charged_notification_text_full, deviceLabel)
} else {
context.getString(R.string.reaction_charged_notification_text_partial, deviceLabel, thresholdPercent)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.reaction_charged_notification_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openPi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
notificationManager.notify(profileId.toTag(), NOTIFICATION_ID, notification)
}
fun cancel(profileId: String) {
notificationManager.cancel(profileId.toTag(), NOTIFICATION_ID)
}
/**
* Drops every charged notification still on screen. Used when monitoring stops — without a
* running monitor the auto-dismiss-on-unplug promise can't be kept, so don't leave them up.
*/
fun cancelAll() {
notificationManager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.tag?.startsWith(TAG_PREFIX) == true }
.forEach { notificationManager.cancel(it.tag, it.id) }
}
private fun String.toTag() = "$TAG_PREFIX$this"
companion object {
private val TAG = logTag("Reaction", "Charged", "Notifications")
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.charged"
private const val NOTIFICATION_ID = 4
private const val TAG_PREFIX = "charged:"
private const val PENDING_INTENT_REQUEST_CODE = 1
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.reaction.core.charged
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Which battery slots the charged notification watches. */
@Serializable
enum class ChargedSlotScope(
@StringRes val labelRes: Int,
) {
@SerialName("charged.scope.pods") PODS(R.string.settings_charged_scope_pods_label),
@SerialName("charged.scope.case") CASE(R.string.settings_charged_scope_case_label),
@SerialName("charged.scope.podsandcase") PODS_AND_CASE(R.string.settings_charged_scope_both_label),
}
@@ -0,0 +1,287 @@
package eu.darken.capod.reaction.core.charged
/**
* Tracks one charging session for one profile and decides when the "charged" notification
* should be shown or cancelled. Pure Kotlin, no Android/DI/Flow dependencies; instances are
* driven sequentially from a single coroutine (no internal locking).
*
* A session starts when any slot reports charging and collects every slot that charges while
* the session runs. Per-slot battery highwater marks decide completion: a slot that stops
* charging at/above the threshold finished naturally (firmware flips the charging flag off at
* 100%), while a slot that stops below the threshold was genuinely unplugged and resets the
* session. Stale data suspends the session instead of resetting it — brief BLE gaps while the
* device sits on the charger must not lose progress.
*
* After firing, the notification is taken down again when the user demonstrably reacted to it:
* battery discharging below the threshold, a new charging session starting, or any activity
* signal showing the pods are being handled (worn-pod set changed, pod removed from the case,
* case lid moved). Activity is judged against baselines captured at fire time so pre-existing
* state doesn't dismiss — only a change does:
* - Worn pods and DISCONNECTED slots baseline on the firing frame. They only apply to
* sessions containing pod slots — wearing pods says nothing about a case-only charge.
* - The lid signal baselines on the UNION of values seen before the fire (this session plus
* a few frames leading into it). With one pod worn and one charging, both pods broadcast
* and the monitor's dedup alternates between their frames (OPEN from the in-case pod,
* NOT_IN_CASE from the worn pod) — a single-frame baseline would read that steady flapping
* as activity. Lid movement is a handling signal for any session, including case-only.
*
* An activity dismissal latches into [Phase.DISMISSED] so a still-full, still-charging device
* can't immediately re-fire.
*/
class ChargingSessionStateMachine {
enum class Slot { LEFT, RIGHT, CASE, HEADSET }
/** [battery] is a fraction 0..1, matching the monitor layer's battery values. */
data class SlotData(val battery: Float, val isCharging: Boolean)
/**
* Case posture from the BLE lid byte. NOT_IN_CASE isn't a lid position — it means the
* broadcasting pod is physically outside the case, which is just as much a handling
* signal as a lid movement (it's the only BLE-side trace of "pod taken out").
*/
enum class Lid { OPEN, CLOSED, NOT_IN_CASE }
/** Signals that the user is physically handling the device. All optional/unknown-safe. */
data class Activity(
/** Pods currently worn (per-side); null when no ear data at all. */
val wornSlots: Set<Slot>? = null,
val lid: Lid? = null,
/** Slots physically absent from the case (AAP ChargingState.DISCONNECTED). */
val disconnectedSlots: Set<Slot> = emptySet(),
)
sealed interface Input {
/**
* Fresh data from a live source (BLE/AAP). [slots] only contains slots with live
* readings — cached values must never be fed in here. [threshold] is a fraction 0..1.
*/
data class LiveUpdate(
val slots: Map<Slot, SlotData>,
val threshold: Float,
val activity: Activity = Activity(),
) : Input
/** Device has no live data right now (out of range, lid closed, cache-only). */
data object StaleUpdate : Input
/** Toggle disabled, settings changed, or profile deleted. */
data object Reset : Input
}
enum class Output { NONE, SHOW, CANCEL }
enum class Phase { IDLE, CHARGING, FIRED, DISMISSED, SUSPENDED }
var phase: Phase = Phase.IDLE
private set
/** Phase to resume into when live data returns; only meaningful while SUSPENDED. */
private var suspendedFrom: Phase = Phase.IDLE
/** Battery highwater per slot that joined the session. */
private val highwater = mutableMapOf<Slot, Float>()
/**
* Rolling window of the most recent lid observations across all phases. Seeds the at-fire
* baseline so the OPEN/NOT_IN_CASE alternation (worn pod broadcasts NOT_IN_CASE while the
* in-case pod broadcasts OPEN; the monitor's dedup serves them in turn) is absorbed — even
* for an instant fire right after a settings-change reset, since the window is never cleared.
*/
private val recentLids = ArrayDeque<Lid>()
// Activity baselines frozen at fire; see class docs.
private val baselineLids = mutableSetOf<Lid>()
private var baselineWorn: Set<Slot>? = null
private var baselineDisconnected: Set<Slot> = emptySet()
fun process(input: Input): Output {
if (input is Input.LiveUpdate) input.activity.lid?.let(::recordRecentLid)
return when (phase) {
Phase.IDLE -> processIdle(input)
Phase.CHARGING -> processCharging(input)
Phase.FIRED -> processFired(input)
Phase.DISMISSED -> processDismissed(input)
Phase.SUSPENDED -> processSuspended(input)
}
}
private fun processIdle(input: Input): Output {
if (input !is Input.LiveUpdate) return Output.NONE
val charging = input.slots.filterValues { it.isCharging }
if (charging.isEmpty()) return Output.NONE
highwater.clear()
charging.forEach { (slot, data) -> highwater[slot] = data.battery }
phase = Phase.CHARGING
return fireIfComplete(input)
}
private fun processCharging(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.CHARGING)
is Input.LiveUpdate -> {
// Charging slots join the session (e.g. case plugged in later); slots already in
// it advance their mark; slots absent from this update stay frozen.
input.slots.forEach { (slot, data) ->
if (data.isCharging || slot in highwater) {
highwater[slot] = maxOf(highwater[slot] ?: 0f, data.battery)
}
}
val unplugged = highwater.any { (slot, mark) ->
input.slots[slot]?.isCharging == false && mark < input.threshold
}
if (unplugged) reset(cancel = false) else fireIfComplete(input)
}
}
private fun processFired(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = true)
is Input.StaleUpdate -> suspend(Phase.FIRED)
is Input.LiveUpdate -> when {
// "All slots stopped charging" alone is NOT an unplug signal — firmware flips the
// charging flag off at 100% while still on power. Slots that never charged this
// session (e.g. an idle half-empty case) carry no signal either. The session ends
// on: a new charge context, discharge below threshold, or user activity.
isNewChargeContext(input) -> {
// Only CANCEL is reported now; the next update finds IDLE and starts (and, if
// the data already satisfies the threshold, fires) the new session.
phase = Phase.IDLE
highwater.clear()
Output.CANCEL
}
isSessionDischarged(input) -> reset(cancel = true)
isActivityDetected(input.activity) -> {
phase = Phase.DISMISSED
Output.CANCEL
}
else -> Output.NONE
}
}
/** Like FIRED, but the notification is already gone — same exits, silent. */
private fun processDismissed(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.DISMISSED)
is Input.LiveUpdate -> when {
isNewChargeContext(input) -> {
// Nothing to cancel, so unlike FIRED the new session can start right away.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
}
isSessionDischarged(input) -> reset(cancel = false)
else -> Output.NONE
}
}
private fun processSuspended(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = suspendedFrom == Phase.FIRED)
is Input.StaleUpdate -> Output.NONE
is Input.LiveUpdate -> {
phase = suspendedFrom
if (phase == Phase.CHARGING && hasRegressed(input)) {
// Battery dropped well below a session mark while we were blind — the device
// was unplugged and used in the meantime. Start over with the fresh data.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
} else {
process(input)
}
}
}
/** Something is charging below the threshold — a fresh charging session is beginning. */
private fun isNewChargeContext(input: Input.LiveUpdate): Boolean =
input.slots.any { it.value.isCharging && it.value.battery < input.threshold }
/** A session slot is off power and drained below the threshold — device is in use. */
private fun isSessionDischarged(input: Input.LiveUpdate): Boolean = highwater.keys.any { slot ->
input.slots[slot]?.let { !it.isCharging && it.battery < input.threshold } == true
}
private fun isActivityDetected(activity: Activity): Boolean {
// Worn pods and pod removal only speak about pod slots; a case-only session ignores them.
if (highwater.keys.any { it != Slot.CASE }) {
activity.wornSlots?.let { worn ->
when (val baseline = baselineWorn) {
// No ear data at fire time: pods can't be worn while charging in the case,
// so any worn pod is activity — only "nothing worn" is adoptable as baseline.
null -> if (worn.isNotEmpty()) return true else baselineWorn = emptySet()
else -> if (worn != baseline) return true
}
}
val newlyDisconnected = activity.disconnectedSlots.any {
it != Slot.CASE && it in highwater && it !in baselineDisconnected
}
if (newlyDisconnected) return true
}
activity.lid?.let { lid ->
if (baselineLids.isEmpty()) {
// Lid never observed pre-fire: first sighting is the baseline — it was most
// likely in that posture all along (open is the normal charging posture).
baselineLids.add(lid)
} else if (lid !in baselineLids) {
return true
}
}
return false
}
private fun hasRegressed(input: Input.LiveUpdate): Boolean = highwater.any { (slot, mark) ->
input.slots[slot]?.let { it.battery < mark - REGRESSION_TOLERANCE } == true
}
private fun recordRecentLid(lid: Lid) {
recentLids.addLast(lid)
while (recentLids.size > RECENT_LID_FRAMES) recentLids.removeFirst()
}
private fun fireIfComplete(input: Input.LiveUpdate): Output {
if (highwater.isEmpty() || highwater.any { it.value < input.threshold }) return Output.NONE
phase = Phase.FIRED
baselineLids.clear()
baselineLids += recentLids
baselineWorn = input.activity.wornSlots
baselineDisconnected = input.activity.disconnectedSlots.intersect(highwater.keys)
return Output.SHOW
}
private fun reset(cancel: Boolean): Output {
phase = Phase.IDLE
highwater.clear()
// recentLids is an ambient rolling window — intentionally kept across resets so an
// instant re-fire after a settings change still has the recent lid alternation.
baselineLids.clear()
baselineWorn = null
baselineDisconnected = emptySet()
return if (cancel) Output.CANCEL else Output.NONE
}
private fun suspend(from: Phase): Output {
suspendedFrom = from
phase = Phase.SUSPENDED
return Output.NONE
}
companion object {
/**
* Battery drop (fraction) below a slot's highwater mark that counts as a real
* regression on resume. Public BLE reports in 10% steps, so a single-step flicker
* (exactly 0.1) must not reset the session.
*/
private const val REGRESSION_TOLERANCE = 0.1f
/**
* Pre-fire lid observations kept as baseline context. At the observed 0.5-1.5s
* emission cadence this is a few seconds — enough to capture dedup alternation
* leading into an instant fire without dragging in long-stale postures.
*/
private const val RECENT_LID_FRAMES = 8
}
}
@@ -0,0 +1,28 @@
package eu.darken.capod.reaction.core.conversation
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What CAPod does when the pods report that the wearer started speaking (Conversational
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
* reaction itself. The action is reverted when speaking stops.
*
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
*/
@Serializable
enum class ConversationAction(
val identifier: String,
@StringRes val labelRes: Int,
) {
@SerialName("conversation.action.nothing")
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
@SerialName("conversation.action.lower_volume")
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
@SerialName("conversation.action.pause")
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
}
@@ -0,0 +1,490 @@
package eu.darken.capod.reaction.core.conversation
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.profiles.core.ReactionConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
*
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP],
* status `8,9`). The pod sends NO frames during active speech — it stays engaged for as long as it
* hears nearby voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely
* against ambient noise). So frame-silence must NOT be read as "speaking ended"; after a START only
* the long [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
*
* A [ConversationAwarenessEvent.HOLD] frame (`3` pause, `0x0B`/`4` wind-down, `7` abort) is evidence
* the wind-down may have begun. The real wind-down is a short flurry `3→0x0B→4` then the `8,9`
* terminal — but the terminal is sometimes dropped entirely (single-pod wear: fw `…6589`/`…6861`
* emitted `3,0xB,4` then nothing, stranding the volume low — #608). So a HOLD frame re-arms the
* timer with the short [WIND_DOWN_TIMEOUT] fuse: if no terminal (or resume) follows, speech is
* treated as ended anyway.
*
* A [ConversationAwarenessEvent.RESUME] frame (`5`) means speech resumed after a pause — in bursty
* talking the pod cycles `3,5,3,5,…` while CA stays engaged. It is NOT a terminal: it cancels any
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
*
* Removing/inserting a pod makes the firmware re-key CA — it emits a terminal then a fresh onset
* around the physical change even though the conversation continues. A terminal arriving within
* [EAR_TRANSITION_WINDOW] of an AAP ear-detection change is therefore treated as a re-key artifact:
* it defers to the wind-down fuse instead of disengaging, so a brief pod removal mid-conversation
* neither strands a pause nor causes a duck→restore→re-duck volume blip.
*
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
* session never strands the user at low volume.
*/
@Singleton
class ConversationReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val deviceMonitor: DeviceMonitor,
private val mediaControl: MediaControl,
@AppScope private val appScope: CoroutineScope,
private val timeSource: TimeSource,
) {
private sealed interface Kind {
data object Paused : Kind
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
}
private data class Active(
val id: Long,
val owner: BluetoothAddress,
val kind: Kind,
val at: Long,
)
/** What the single pending [disengageJob] is currently waiting for. */
private enum class TimerPhase {
/** Long backstop while only START/RESUME seen — inferred end if it fires. */
STALE_BACKSTOP,
/** Short fuse armed by a wind-down HOLD — terminal imminent (or dropped, #608). */
WIND_DOWN_FUSE,
/** Brief wait on an uncorroborated cold terminal to see if a pod-removal ear change follows. */
STOP_SETTLE,
}
private val mutex = Mutex()
private var active: Active? = null
private var disengageJob: Job? = null
private var timerPhase: TimerPhase? = null
// Bumped on every (re)arm. A timer job captures its generation and only acts if still current —
// guards against a stale job that already passed its delay (so cancel() no longer stops it) being
// re-armed to the SAME phase while it blocks on the mutex. This is a runtime concurrency guard: the
// single-threaded virtual-time test harness serializes the timer callback and event handlers, so a
// re-arm there always cancels the prior job before it runs — the race is not reproducible in a unit
// test (a test would exercise cancel(), not this check). Left deliberately uncovered, not an oversight.
private var timerGeneration = 0L
private var idCounter = 0L
// Per-owner AAP ear-detection tracking, so a CA terminal that lands right after a pod
// removal/insertion can be recognised as a re-key artifact rather than a real conversation end.
private val lastEarDetection = mutableMapOf<BluetoothAddress, AapSetting.EarDetection?>()
private val earTransitionAt = mutableMapOf<BluetoothAddress, Long>()
fun monitor(): Flow<Unit> = merge(
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
// Tracks ear-detection transitions (for terminal-artifact suppression) and reverts a stranded
// duck when the owning device leaves the AAP state map for any reason (intentional or not) —
// disconnectEvents only fires for unintentional drops.
aapManager.allStates.onEach { states -> onStatesUpdated(states) },
)
.map { }
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
.setupCommonEventHandlers(TAG) { "conversationReaction" }
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
ConversationAwarenessEvent.START -> onSpeakingStart(address)
ConversationAwarenessEvent.RESUME -> onSpeakingResume(address)
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
}
private suspend fun onSpeakingStart(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
if (primary?.address != address) {
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
return
}
val action = primary.reactions.conversationAction
if (action == ConversationAction.NOTHING) return
mutex.withLock {
val current = active
if (current != null && current.owner == address) {
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
armTimer(current, TimerPhase.STALE_BACKSTOP)
log(TAG) { "START from $address — already active ($action), keep-alive" }
return
}
// A different device started speaking while we were active — undo the old one first and
// cancel its pending timer (the no-op engage paths below would otherwise leak it).
if (current != null) {
revert(current, "superseded by $address")
clearActive()
}
when (action) {
ConversationAction.PAUSE -> {
val paused = mediaControl.sendPause(rememberForResume = false)
if (paused) {
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
active = record
armTimer(record, TimerPhase.STALE_BACKSTOP)
log(TAG, INFO) { "START on $address → paused media" }
} else {
active = null
log(TAG) { "START on $address → nothing playing, no pause" }
}
}
ConversationAction.LOWER_VOLUME -> {
val reduction = primary.reactions.conversationVolumeReduction
.coerceIn(
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
)
val duck = mediaControl.duckMusicVolume(reduction)
if (duck != null) {
val record = Active(
nextId(),
address,
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
timeSource.elapsedRealtime(),
)
active = record
armTimer(record, TimerPhase.STALE_BACKSTOP)
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}${duck.appliedVolume}" }
} else {
active = null
log(TAG) { "START on $address → duck no-op" }
}
}
ConversationAction.NOTHING -> Unit
}
}
}
/**
* Speech resumed (status `5`) after a pause — the wind-down was aborted, the wearer is talking
* again. Cancel any armed wind-down fuse and re-arm the long [STALE_TIMEOUT] backstop, exactly
* like a duplicate-START keep-alive, so media stays paused/ducked through the conversation. Does
* NOT engage from scratch: a RESUME with no active session is ignored (a conversation always
* opens with a START, and a stray `5` should never start a pause/duck on its own).
*/
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
armTimer(current, TimerPhase.STALE_BACKSTOP)
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
}
/**
* Transitional wind-down frame (`3` pause, `0x0B`/`4` wind-down, `7` abort). The pod is silent
* during active speech, so this frame means the wind-down may have begun and a terminal frame is
* imminent — but firmware sometimes drops it (#608, single-pod wear). Arm the short fuse: if no
* terminal (or a fresh START / RESUME) follows, disengage anyway.
*/
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
}
private suspend fun onSpeakingStop(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
val current = active ?: return
if (current.owner != address) {
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
return
}
// The 0x06 ear-detection frame may have arrived but not yet been processed by the
// allStates collector — fold the latest snapshot in before deciding (in-app ordering race).
refreshEarTransition(address)
// Pulling/inserting a pod makes the firmware re-key CA — a terminal (8,9) then a fresh
// onset (1,2) around the physical change, even though the conversation continues.
when {
// Ear change already seen → re-key artifact. Defer to the fuse; a re-onset cancels it.
isRecentEarTransition(address) -> {
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
log(TAG) { "STOP from $address during ear transition — deferring to fuse" }
}
// Corroborated by a preceding wind-down (3,0xB,4 / abort 7): a real end → resume now.
timerPhase == TimerPhase.WIND_DOWN_FUSE -> {
clearActive()
disengage(current, primary, "STOP on $address", applyAgeGuard = false)
}
// Cold terminal with no wind-down and no ear change yet. On primary-pod removal the
// firmware sends the terminal tens of ms BEFORE the ear-detection frame (measured ~30ms
// on Pro 3), so wait a short settle to see if an ear change lands before treating it as
// a real end.
else -> {
armTimer(current, TimerPhase.STOP_SETTLE)
log(TAG) { "STOP from $address — uncorroborated, settling" }
}
}
}
}
/**
* Graceful disengage (STOP event or timer expiry). Must be called under [mutex]. [applyAgeGuard]
* gates resume on [PAUSE_RESUME_WINDOW]: `true` only for the inferred stale-backstop end (we lost
* track and timed out — surprise-resuming long after is worse than a stranded pause); `false` for
* an explicit terminal or the wind-down fuse, where a recent/real end signal makes resume correct.
*/
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String, applyAgeGuard: Boolean) {
when (val kind = record.kind) {
is Kind.Paused -> {
// Resume the pause WE caused, regardless of the current action setting. Gating on
// "action still == PAUSE" would strand media paused if the user switched the action
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
// surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at
when {
applyAgeGuard && age.milliseconds > PAUSE_RESUME_WINDOW ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
wornForResume(primary) == false ->
log(TAG) { "$reason — resume skipped (not worn)" }
mediaControl.isPlaying ->
log(TAG) { "$reason — resume skipped (already playing)" }
else -> {
mediaControl.sendPlay()
log(TAG, INFO) { "$reason → resumed media" }
}
}
}
is Kind.Ducked -> revertDuck(kind, reason)
}
}
private suspend fun onStatesUpdated(states: Map<BluetoothAddress, AapPodState>) = mutex.withLock {
// Record when each device's AAP ear-detection last changed (pod removed/inserted/cased).
for ((address, state) in states) recordEarIfChanged(address, state.aapEarDetection)
lastEarDetection.keys.retainAll(states.keys)
earTransitionAt.keys.retainAll(states.keys)
val current = active ?: return
if (current.owner !in states.keys) {
clearActive()
revert(current, "owner ${current.owner} gone")
}
}
/**
* Must be called under [mutex]. Notes an AAP ear-detection transition for [address]. The very
* first observation of a device only seeds the baseline — it is not counted as a transition.
*/
private fun recordEarIfChanged(address: BluetoothAddress, ear: AapSetting.EarDetection?) {
if (lastEarDetection.containsKey(address) && lastEarDetection[address] != ear) {
earTransitionAt[address] = timeSource.elapsedRealtime()
}
lastEarDetection[address] = ear
}
/** Must be called under [mutex]. Folds the latest allStates snapshot in for [address]. */
private fun refreshEarTransition(address: BluetoothAddress) {
recordEarIfChanged(address, aapManager.allStates.value[address]?.aapEarDetection)
}
private suspend fun onMonitorCompleted() = mutex.withLock {
val current = active ?: return
clearActive()
revert(current, "monitor completed")
}
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
private fun revert(record: Active, reason: String) {
when (val kind = record.kind) {
is Kind.Ducked -> revertDuck(kind, reason)
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
}
}
private fun revertDuck(kind: Kind.Ducked, reason: String) {
// Restore to the pre-duck volume unconditionally — we do NOT gate on
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
// baseline ratchet down across conversations. Restoring deterministically to the level we
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
// volume change (rare). Matches librepods' unconditional restore.
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
mediaControl.restoreMusicVolume(kind.priorVolume)
}
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
private fun clearActive() {
disengageJob?.cancel()
disengageJob = null
timerPhase = null
active = null
}
/**
* Must be called under [mutex]. (Re)arms the single disengage timer for [record] in [phase],
* replacing whatever was pending. On expiry [onTimerExpired] decides per phase. Guarded on both
* [Active.id] and [phase] so a late timer can't act on a newer session or a re-armed phase.
*/
private fun armTimer(record: Active, phase: TimerPhase) {
disengageJob?.cancel()
timerPhase = phase
val generation = ++timerGeneration
val timeout = when (phase) {
TimerPhase.STALE_BACKSTOP -> STALE_TIMEOUT
TimerPhase.WIND_DOWN_FUSE -> WIND_DOWN_TIMEOUT
TimerPhase.STOP_SETTLE -> STOP_SETTLE_DELAY
}
disengageJob = appScope.launch {
delay(timeout)
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
// generation guards against a stale job re-armed to the same phase for the same record.
if (active?.id == record.id && timerGeneration == generation) onTimerExpired(record, phase, primary)
}
}
}
/**
* Must be called under [mutex] from inside the firing timer job. Detaches the slot WITHOUT
* cancelling (we ARE that job — self-cancel could abort a following suspension), then acts:
* - STOP_SETTLE + an ear change appeared meanwhile → re-key artifact, hand off to the fuse.
* - otherwise force-end the session. The age guard applies only to the inferred STALE backstop;
* the wind-down fuse and a settled cold terminal carry real/recent end evidence → resume regardless.
*/
private suspend fun onTimerExpired(record: Active, phase: TimerPhase, primary: PodDevice?) {
disengageJob = null
timerPhase = null
if (phase == TimerPhase.STOP_SETTLE && isRecentEarTransition(record.owner)) {
armTimer(record, TimerPhase.WIND_DOWN_FUSE)
log(TAG) { "STOP settle elapsed for ${record.owner} — ear transition appeared, deferring to fuse" }
return
}
active = null
val reason = when (phase) {
TimerPhase.STALE_BACKSTOP -> "no terminal within backstop"
TimerPhase.WIND_DOWN_FUSE -> "no terminal within wind-down fuse"
TimerPhase.STOP_SETTLE -> "cold terminal settled"
}
disengage(record, primary, reason, applyAgeGuard = phase == TimerPhase.STALE_BACKSTOP)
}
/**
* Whether the pod is worn enough to resume into, honoring One-Pod Mode (mirrors
* [eu.darken.capod.reaction.core.autoconnect.AutoConnect]): with One-Pod Mode on, a single pod
* in ear counts as worn, falling back to the both-pods reading only when the single-pod signal is
* unknown. Returns `null` when ear state is genuinely unknown — the caller treats only an explicit
* `false` as not-worn, so an unknown stays lenient (resumes).
*/
private fun wornForResume(device: PodDevice): Boolean? =
if (device.reactions.onePodMode) device.isEitherPodInEar ?: device.isBeingWorn
else device.isBeingWorn
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
val at = earTransitionAt[address] ?: return false
return (timeSource.elapsedRealtime() - at).milliseconds <= EAR_TRANSITION_WINDOW
}
private fun nextId(): Long = ++idCounter
companion object {
private val TAG = logTag("Reaction", "Conversation")
/**
* Backstop fuse while engaged with no wind-down evidence yet (START/RESUME frames seen).
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
* while the link stays up. Kept longer than [PAUSE_RESUME_WINDOW] so a back-stopped pause
* is never auto-resumed (only a duck is restored — a stranded low volume is the worse
* failure).
*/
private val STALE_TIMEOUT = 5.minutes
/**
* Short fuse armed by a transitional [ConversationAwarenessEvent.HOLD] frame: the wind-down
* has begun, so a terminal frame should follow within seconds. With only one pod worn the
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
* this fuse. A fresh START or RESUME (`5`, speech resumed) re-arms the long fuse instead.
*/
private val WIND_DOWN_TIMEOUT = 6.seconds
/**
* For an *inferred* end only (the [STALE_TIMEOUT] backstop fired — we lost track of the
* conversation), a pause older than this no longer auto-resumes: surprise playback long after
* is worse than a stranded pause. An explicit terminal frame, or the short wind-down fuse,
* resumes regardless of age — those carry real/recent evidence the conversation just ended.
*/
private val PAUSE_RESUME_WINDOW = 2.minutes
/**
* A CA terminal landing within this window of an AAP ear-detection change is treated as a pod
* removal/insertion re-key artifact (firmware emits 8,9 then 1,2 around the physical change),
* not a real end. The artifact terminal and the ear change land within tens of ms of each other
* (measured ~30ms on Pro 3, either side depending on which pod); a real end is seconds away.
* Kept well under that gap so genuine terminals aren't deferred to the fuse. (The ear-detection
* 0x06 frames are emitted on removal even when the EarDetectionEnabled setting is off, so this
* suppression does not depend on that setting being enabled.)
*/
private val EAR_TRANSITION_WINDOW = 2.seconds
/**
* How long to wait on an *uncorroborated cold* terminal (no preceding wind-down HOLD, no ear
* change yet) before treating it as a real end. Covers the reverse frame-ordering on
* primary-pod removal, where the firmware sends the terminal tens of ms BEFORE the ear-detection
* frame (measured ~30ms on Pro 3) — too early for the backward-looking check. Only cold
* terminals settle; a normal end
* (`…3,0xB,4,8`) and an abort (`7,8`) are already corroborated by the fuse and resume instantly,
* so this adds no latency to genuine conversation ends.
*/
private val STOP_SETTLE_DELAY = 250.milliseconds
}
}
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -179,6 +180,7 @@ class PlayPause @Inject constructor(
onePodMode = reactions.onePodMode,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
startMusicOnWear = reactions.startMusicOnWear,
)
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
@@ -204,6 +206,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
startMusicOnWear = reactions.startMusicOnWear,
)
pendingPlayConfirmation = confirmation.pending
@@ -221,6 +224,8 @@ class PlayPause @Inject constructor(
rawDecision = confirmation.decision,
currentState = currState,
autoPauseEnabled = reactions.autoPause,
now = current.ble?.seenLastAt,
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
)
pendingPauseDebounce = debounceResult.pending
@@ -238,7 +243,7 @@ class PlayPause @Inject constructor(
"rawShouldPlay=${confirmation.decision.shouldPlay}"
}
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
"Pause debounce committed: source=$source confirmed pause"
"Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
}
PauseDebounceEvent.NONE -> {}
}
@@ -263,7 +268,7 @@ class PlayPause @Inject constructor(
}
decision.shouldPause && reactions.autoPause -> {
val pauseSent = mediaControl.sendPause()
val pauseSent = mediaControl.sendPause(rememberForResume = true)
log(TAG, INFO) {
"autoPause triggered: source=$source, " +
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
@@ -288,10 +293,11 @@ class PlayPause @Inject constructor(
onePodMode: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean = false,
startMusicOnWear: Boolean = false,
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
}
private fun evaluateOnePodMode(
@@ -299,6 +305,7 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
@@ -310,8 +317,13 @@ class PlayPause @Inject constructor(
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
)
// Net increase: pod(s) inserted → play
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
netChange > 0 && (
wasRecentlyPausedByUs ||
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
@@ -332,13 +344,19 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByCap: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn, and not playing → play
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
// Transition: not worn → worn → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
!wasWorn && isWorn && (
wasRecentlyPausedByCap ||
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "Normal mode: both pods in ear",
@@ -371,6 +389,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
startMusicOnWear: Boolean = false,
): PlayConfirmationResult {
val activePending = pending?.takeIf {
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
@@ -378,7 +397,10 @@ class PlayPause @Inject constructor(
if (activePending != null &&
currentState == activePending.targetState &&
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
(
wasRecentlyPausedByUs ||
(startMusicOnWear && !isCurrentlyPlaying)
)
) {
return PlayConfirmationResult(
decision = PlayPauseDecision(
@@ -419,12 +441,21 @@ class PlayPause @Inject constructor(
}
/**
* Sample-count debounce for pause decisions when the ear-detection source is an
* unauthenticated BLE advertisement.
* Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
* is an unauthenticated BLE advertisement.
*
* RF interference can produce a single corrupt advert that decodes as not-worn,
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
* 3 consecutive not-worn samples before firing.
* RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
* false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
* samples before firing on the *count* path.
*
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
*
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
* — subsequent samples after the initial detection are not-worn → not-worn, and
@@ -443,6 +474,8 @@ class PlayPause @Inject constructor(
rawDecision: PlayPauseDecision,
currentState: EarDetectionState,
autoPauseEnabled: Boolean,
now: Instant? = null,
generatedAtNanos: Long? = null,
): PauseDebounceResult {
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
@@ -506,6 +539,8 @@ class PlayPause @Inject constructor(
profileId = profileId,
initialPodCount = currentState.podCount,
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
startedAt = now,
startedGeneratedAtNanos = generatedAtNanos,
),
event = PauseDebounceEvent.STARTED,
)
@@ -526,7 +561,10 @@ class PlayPause @Inject constructor(
shouldPause = false,
reason = "Debouncing pause (rebound tolerated)",
),
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
pending = activePending.copy(
resetTolerance = activePending.resetTolerance - 1,
reboundTolerated = true,
),
event = PauseDebounceEvent.ADVANCED,
)
}
@@ -535,12 +573,41 @@ class PlayPause @Inject constructor(
// Confirmation: count <= initialPodCount, decrement remaining.
val remaining = activePending.confirmationsRemaining - 1
if (remaining <= 0) {
val commitOnCount = remaining <= 0
// Time-cap early commit: once the not-worn condition has persisted at least
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
// the "≥2 consecutive, distinct not-worn receptions" invariant:
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
// path is disabled and we fall back to pure sample-count.
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
// broken/constant OEM timebase just disables the time path (fail-safe to count).
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
// only delay, never prematurely fire.
val startedAt = activePending.startedAt
val startedNanos = activePending.startedGeneratedAtNanos
val elapsed = if (startedAt != null && now != null) {
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
} else {
null
}
val commitOnTime = !activePending.reboundTolerated &&
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
startedNanos != null && generatedAtNanos != null &&
generatedAtNanos != startedNanos
if (commitOnCount || commitOnTime) {
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
return PauseDebounceResult(
decision = PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
reason = "Debounced pause confirmed via $mode " +
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
),
pending = null,
event = PauseDebounceEvent.COMMITTED,
@@ -655,6 +722,18 @@ class PlayPause @Inject constructor(
// shows a pod returning shouldn't kill the pending, since the next sample may
// confirm the pods are still out.
val resetTolerance: Int = 1,
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
// not-worn sample. null → the time-cap early commit is inactive for this pending.
val startedAt: Instant? = null,
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
// not-worn sample. The time-cap commit requires the confirming sample to be a
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
// same cached advert in a later batch cannot early-commit on one physical advert.
val startedGeneratedAtNanos: Long? = null,
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
// (the not-worn samples are no longer strictly consecutive), falling back to pure
// sample-count confirmation.
val reboundTolerated: Boolean = false,
)
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
@@ -782,5 +861,19 @@ class PlayPause @Inject constructor(
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
*/
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
/**
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
* not-worn condition has persisted this long (measured from the first not-worn sample's
* observation time) with at least one further *distinct* not-worn reception and no
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
*
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
* see no change (count commits first). Tunable.
*/
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
}
}
@@ -11,11 +11,13 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import java.time.Duration
@@ -32,10 +34,14 @@ class PopUpReaction @Inject constructor(
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDeviceByTier
.distinctUntilChangedBy {
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid).
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex)
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
// lid state. The latter is essential: caseLidState is recovered from history, so it can
// flip OPEN<->CLOSED while the *selected* frame's raw bytes stay identical (e.g. a steady
// out-of-case frame while a sibling in-case frame updates). Keying on rawDataHex alone
// would swallow that transition and the popup would miss its show/hide.
listOf(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex, it?.caseLidState)
}
.withPrevious()
.setupCommonEventHandlers(TAG) { "popUpCase" }
@@ -71,7 +77,7 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current)
}
private fun throttleCasePopUps(current: PodDevice): Event? {
internal fun throttleCasePopUps(current: PodDevice): Event? {
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
val now = timeSource.now()
val lastShown = caseCoolDowns[cooldownKey]
@@ -95,9 +101,9 @@ class PopUpReaction @Inject constructor(
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
// Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
// here would let a transient UNKNOWN (e.g. a brief out-of-case frame) suppress a
// genuine OPEN for the whole cooldown window. CLOSED still resets it above.
Event.PopupHide(now)
}
@@ -115,7 +121,7 @@ class PopUpReaction @Inject constructor(
private fun monitorConnection(): Flow<Event> = combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().distinctUntilChangedBy {
deviceMonitor.primaryDeviceByTier.distinctUntilChangedBy {
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
},
) { devices, broadcast ->
@@ -192,7 +198,48 @@ class PopUpReaction @Inject constructor(
}
.setupCommonEventHandlers(TAG) { "popUpConnection" }
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
/**
* Backstop for case-open popups that never receive a CLOSED frame because the device left BLE
* range while the lid was open — otherwise the overlay lingers until manually dismissed (one of
* the symptoms in #598). A ticker re-checks the primary device's freshness; once a previously
* fresh OPEN broadcast goes stale past [CASE_OPEN_STALE_TIMEOUT] (no newer advertisement, or the
* device dropped to cache-only), a single Hide is emitted. The lid-driven [monitorCase] still
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
*/
private fun monitorCaseStaleClose(): Flow<Event> = combine(
deviceMonitor.primaryDeviceByTier,
staleCheckTicker(),
) { device, _ -> isCaseOpenBroadcastFresh(device) }
.distinctUntilChanged()
.withPrevious()
.mapNotNull { (wasFresh, isFresh) ->
if (wasFresh == true && !isFresh) {
log(TAG) { "Case-open broadcast went stale, emitting Hide" }
Event.PopupHide(timeSource.now())
} else {
null
}
}
.setupCommonEventHandlers(TAG) { "popUpCaseStale" }
/** True while the primary device is eligible and currently advertising a fresh OPEN lid. */
internal fun isCaseOpenBroadcastFresh(device: PodDevice?): Boolean {
if (device?.reactions?.showPopUpOnCaseOpen != true) return false
if (device.caseLidState != DualApplePods.LidState.OPEN) return false
// Track BLE freshness specifically, not PodDevice.seenLastAt (which also counts AAP/cache):
// the lid is a BLE-only signal, so a live AAP socket must not keep a stale OPEN on screen.
val bleSeenLastAt = device.ble?.seenLastAt ?: return false
return Duration.between(bleSeenLastAt, timeSource.now()) <= CASE_OPEN_STALE_TIMEOUT
}
private fun staleCheckTicker(): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(STALE_CHECK_INTERVAL.toMillis())
}
}
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection(), monitorCaseStaleClose())
sealed class Event {
data class PopupShow(
@@ -296,5 +343,11 @@ class PopUpReaction @Inject constructor(
companion object {
private val TAG = logTag("Reaction", "PopUp")
/** A case-open popup is force-dismissed once its OPEN broadcast hasn't refreshed for this long. */
private val CASE_OPEN_STALE_TIMEOUT: Duration = Duration.ofSeconds(4)
/** How often the stale-close backstop re-evaluates freshness. */
private val STALE_CHECK_INTERVAL: Duration = Duration.ofSeconds(2)
}
}
@@ -8,8 +8,8 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
@@ -23,14 +23,13 @@ import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
@@ -43,12 +42,14 @@ class TroubleShooterViewModel @Inject constructor(
private val blePodMonitor: BlePodMonitor,
private val bleScanModeController: BleScanModeController,
private val deviceMonitor: DeviceMonitor,
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
/** Guards against re-entrant runs (e.g. a double tap on "Try again"). */
private val runLock = Mutex()
data class State(val bleState: BleState)
val state = _bleState.map { State(it) }.asLiveState()
@@ -84,166 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
}
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
if (!runLock.tryLock()) {
log(TAG, WARN) { "troubleShootBle() ignored, a run is already in progress" }
return@launch
}
log(TAG, INFO) { "troubleShootBle()" }
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
try {
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
// The combo under which supported headphones were detected (set in sweep 2).
var supportedCombo: BlePodMonitor.CompatOverride? = null
// Set only when the run reaches a terminal success. Persisted on exit; staying null
// (any failure, or cancellation) means we just clear the override, which restores the
// user's original — never-touched — settings.
var comboToPersist: BlePodMonitor.CompatOverride? = null
try {
run {
progress("Checking for headphones...")
if (findLiveBleHeadphones()) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
{ combo, unfiltered ->
progress(
"SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
"batchingDisabled=${combo.offloadedBatchingDisabled}, " +
"indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
)
blePodMonitor.setCompatOverride(combo)
blePodMonitor.setUnfilteredOverride(unfiltered)
val devices = collectFreshDevices()
log(TAG) { "SCAN: Fresh BLE devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
} else {
progress("SCAN: No data received")
}
devices
}
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
run {
progress("Checking if we can receive BLE data at all.")
val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
if (!gotData) {
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
return@override
}
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
doScan(combo, false).any { it !is UnknownSnapshotBle }
}
if (supportedCombo == null) {
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = collectFreshDevices()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxByOrNull { it.signalQuality }
if (candidate == null) {
failure(
"No supported headphones found near your device.",
BleState.Result.Failure.Type.HEADPHONES,
)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
try {
// Persist the winning combo before dropping the override so the effective scan
// settings stay equal with no restart flicker. Done in its own try so the
// override is still cleared (restoring originals) even if a write throws.
comboToPersist?.let { persistCompat(it) }
} finally {
blePodMonitor.setCompatOverride(null)
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
debugSettings.showUnfiltered.valueBlocking = false
return@override
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = timeSource.elapsedRealtime()
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
runLock.unlock()
}
}
/**
* Waits up to [STEP_TIME] for the primary profile to be backed by a *fresh, live BLE*
* observation. A cached-only / AAP-only primary does not count — the troubleshooter is about
* whether BLE advertisements are actually reaching us. The freshness cutoff (snapshot seen at or
* after this call) means it reflects the currently-active scan settings and doesn't rely on the
* device cache having been cleared beforehand.
*/
private suspend fun findLiveBleHeadphones(): Boolean {
val threshold = timeSource.now()
return withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().firstOrNull { device ->
device?.ble != null && (device.seenLastAt?.let { it >= threshold } == true)
} != null
} ?: false
}
/**
* Collects BLE devices observed *after the current scan settings take effect*. Option changes
* restart the scan after a throttle, and [BlePodMonitor] keeps a 20s device cache, so without a
* freshness cutoff a stale observation from a previous combo could be mistaken for a "win".
*/
private suspend fun collectFreshDevices(): List<BlePodSnapshot> {
// Drop anything cached under a previous combo so it can't satisfy this attempt.
blePodMonitor.clearDeviceCache()
val freshThreshold = timeSource.now().plusMillis(SCAN_SETTLE_MS)
val start = timeSource.elapsedRealtime()
val collected = mutableListOf<BlePodSnapshot>()
// Accumulate as we go: a timeout must not discard what we already observed. toList() only
// returns once the flow completes, which a quiet channel may never do within the window.
withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.collect { snapshots -> collected.addAll(snapshots) }
}
return collected
.filter { it.seenLastAt >= freshThreshold }
.distinctBy { it.address }
}
private fun persistCompat(combo: BlePodMonitor.CompatOverride) {
generalSettings.isOffloadedFilteringDisabled.valueBlocking = combo.offloadedFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = combo.offloadedBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = combo.indirectCallback
}
sealed class BleState {
class Intro : BleState()
@@ -294,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
companion object {
const val STEP_TIME = 10 * 1000L
/**
* Grace period after switching compat settings before an observation counts as "fresh".
* Covers [BlePodMonitor]'s ~1s scan-option throttle plus the scanner restart.
*/
const val SCAN_SETTLE_MS = 1500L
/**
* Compatibility combinations to probe, ordered fewest-disables-first so the first one that
* works (and gets persisted) is the *minimal* set of overrides — e.g. a phone that only
* needs batching disabled won't also get filtering disabled. Triple semantics:
* (offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).
*/
val COMPAT_COMBOS: List<BlePodMonitor.CompatOverride> = listOf(
BlePodMonitor.CompatOverride(false, false, false), // baseline (no overrides)
BlePodMonitor.CompatOverride(false, true, false), // batching only
BlePodMonitor.CompatOverride(true, false, false), // filtering only
BlePodMonitor.CompatOverride(false, false, true), // indirect callback only
BlePodMonitor.CompatOverride(false, true, true), // batching + indirect
BlePodMonitor.CompatOverride(true, false, true), // filtering + indirect
BlePodMonitor.CompatOverride(true, true, false), // filtering + batching
BlePodMonitor.CompatOverride(true, true, true), // everything
)
val TAG = logTag("TroubleShooter", "VM")
}
}
@@ -12,7 +12,7 @@
<clip>
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="?android:attr/colorAccent" />
<solid android:color="@color/brand_primary" />
</shape>
</clip>
</item>
@@ -24,8 +24,8 @@
android:id="@+id/pod_left_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_left" />
<ProgressBar
@@ -45,7 +45,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="95%" />
tools:text="95% · 5h 40m" />
<LinearLayout
android:layout_width="36dp"
@@ -58,8 +58,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -67,8 +67,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -88,8 +88,8 @@
android:id="@+id/pod_case_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_case" />
<ProgressBar
@@ -122,8 +122,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
@@ -142,8 +142,8 @@
android:id="@+id/pod_right_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_right" />
<ProgressBar
@@ -163,7 +163,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="92%" />
tools:text="92% · 5h 25m" />
<LinearLayout
android:layout_width="36dp"
@@ -176,8 +176,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -185,8 +185,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -38,6 +38,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -46,6 +48,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -76,6 +80,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
@@ -105,6 +111,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -113,6 +121,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>

Some files were not shown because too many files have changed in this diff Show More