Compare commits

...
Author SHA1 Message Date
Matthias Urhahn 911a09c743 fix(permissions): Offer battery optimization card in AUTOMATIC mode 2026-07-17 17:24:48 +02:00
Matthias Urhahn 2672dbdcf6 feat(monitor): Detect OS kills of the monitor and show a recovery hint 2026-07-17 17:24:41 +02:00
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
d4rken-org-releaser[bot] 9d41979499 Release: 5.1.5-rc0 2026-05-06 10:16:00 +00:00
darken 985b73754e fix: Eliminate Float? boxing from battery display and cache merge
Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.

Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
2026-05-06 11:54:25 +02:00
Matthias Urhahn e4e4190523 General: Update zh-CN phone screenshots 2026-05-04 22:10:14 +02:00
Matthias Urhahn ebfb034fdd fix(navigation): Defer onNewIntent navCtrl call until Compose attaches
Move consumeUpgradeExtra() out of MainActivity.onNewIntent and into a Compose-level LaunchedEffect that collects a new MainActivity.warmIntents SingleEventFlow.

onNewIntent could fire before the setContent{} lambda registered the back stack with NavigationController, leaving navCtrl.goTo(Nav.Main.Upgrade) to throw IllegalStateException("NavigationController not initialized"). Same race shipped a fix for in octi (d4rken-org/octi#284).
2026-05-04 21:42:53 +02:00
Matthias Urhahn 8a81d354e6 chore(screenshots): Improve dashboard and add-profile previews
Use AAP-connected mocks for the dark dashboard to showcase full connectivity badges (BLE + IRK + encrypted + AAP) and the ANC mode selector. Populate the add-profile preview with a demo name, model, and paired device. Add user-facing labels to the dashboard mock devices.

Refactor BluetoothDevice2 so name/address are primary constructor fields, letting previews construct one without a real Android BluetoothDevice. Bump the screenshot test JVM heap to 4g — the smoke batch (42 renders) was hitting the test executor's default ceiling.
2026-05-04 21:30:25 +02:00
Matthias Urhahn ac0c4693dc chore(screenshots): Switch to smoke-only Play Store screenshot commits
Mirrors permission-pilot's policy: only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) check phoneScreenshots PNGs into the repo. Non-smoke locales are excluded via .gitignore. Drops fastlane/metadata/android/ from ~67 MB to ~7 MB and prevents future bloat from full regens. Play Store's supply retains previously-uploaded screenshots for locales not pushed, so full localization is maintained by occasional manual regen + screenshots_only upload.
2026-05-04 19:27:57 +02:00
Matthias Urhahn 4fd7a41c53 chore(screenshots): Regenerate Play Store screenshots
Replaces stale 7_settings.png and 8_reaction_settings.png with the current 7_device_settings_reactions.png across all 68 locales.
2026-05-04 18:50:30 +02:00
Matthias Urhahn cc1caf47f0 chore(fastlane): Add screenshots_only supply lane 2026-05-04 18:32:00 +02:00
darken 8b9f953818 feat(widget): Show profile name and nearby state when ANC widget is disconnected 2026-05-04 13:03:27 +02:00
d4rken-org-releaser[bot] d541de8c38 Release: 5.1.4-rc0 2026-05-03 17:32:42 +00:00
darken 2821da434d fix(settings): Hide Microphone & Press controls when AAP isn't ready 2026-05-03 19:27:52 +02:00
darken 07c72dc86c fix(aap): Cancel hung BluetoothSocket connect on timeout
BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.

Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).

Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
2026-05-03 15:39:03 +02:00
darken 7d93e772b4 fix(monitor): Prevent NPE in cache merge from freezing device flow
Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
2026-05-03 14:20:49 +02:00
darken 82dc88faac fix: Harden uncaught exception safety net
Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
2026-05-03 12:41:32 +02:00
darken 06b41ec8eb fix(monitor): Recover from revoked Bluetooth scan permission 2026-05-03 11:54:11 +02:00
darken acc6e92b2d feat(tile): Add ANC Quick Settings tile 2026-05-03 10:58:05 +02:00
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
d4rken-org-releaser[bot] 3f310016b1 Release: 5.1.2-rc1 2026-05-01 07:04:37 +00:00
darken d399a6d908 fix(ci): Drop redundant explicit dispatch in release-prepare 2026-05-01 09:00:29 +02:00
d4rken-org-releaser[bot] fb104cbe61 Release: 5.1.2-rc0 2026-05-01 06:56:07 +00:00
darken 97cbca7a6e fix(ci): Resolve bot slug via action output to avoid JWT-only endpoint 2026-05-01 08:55:15 +02:00
darken 02278dab17 chore(ci): Use GitHub App token for release-prepare push 2026-05-01 08:50:32 +02:00
darken 2240bd7ae5 chore(ci): Drop env gate on release-prepare to keep two-click approval 2026-05-01 08:16:33 +02:00
darken bc4096cb57 chore(ci): Run bats + shellcheck on release tooling 2026-05-01 08:00:15 +02:00
darken 5a0ba1b0bd chore(ci): Move release process to GitHub Actions 2026-05-01 08:00:15 +02:00
darken d04abdf80c General: Fix translation issues across multiple locales
Targeted character / escape / vandalism fixes:

- tr: %80 -> 80%% (UnknownFormatConversionException crash risk)

- pt: replace Cyrillic 'не' with 'não' in autoconnect description

- sk: fix 'ladUciaho' -> 'ladiaceho' (corruption)

- ms: restore 'Discord' brand name

- ca: 'maxima' -> 'minima' for signal_quality_title; fix 'suprimrira' typo

- zh-rTW: fix homophones and Japanese-kanji variants (4 strings)

- zh-rHK: Simplified -> Traditional for press_action_fast_forward

- zu: remove spurious 'Zn' prefix in 2 strings (vandalism)

- lv: fix wrong cedilla character in 3 Bluetooth a11y descriptions

- kmr-rTR: fix 4 encoding artifacts (a-feminine-ordinal substituted for e-circumflex)

- ko: fix 2 Hangul typos (conversation_awareness, allow_off)

Garbled translations removed (fall back to English source):

- hy-rAM: 13 press_controls/press_action strings

- mn-rMN: 2 strings (aap_unavailable_description, listening_mode_cycle_minimum)

- lt: settings_popup_warning_manual_mode

- mk-rMK: device_settings_allow_off_description (Syriac char vandalism)

- lo-rLA: signal_badge_ble_cd (English fallback was effectively unchanged)

- km-rKH: device_settings_microphone_mode_label (Thai script in Khmer file)
2026-05-01 05:19:34 +02:00
darken 0609352cc9 General: Update app translations from Crowdin 2026-05-01 05:19:34 +02:00
darken abde0b7d6a feat(reactions): Add discovery hint and outline settings icon 2026-04-30 17:46:27 +02:00
darken b952aff38d fix(widget): Re-render widgets reactively after initial session start
Glance only calls provideGlance() once per widget session; subsequent update() calls recompose the existing composition without re-running provideGlance. The previous capture-once approach left widgets frozen at initial state because the composition had no reactive State to read.

Subscribe to a widgetDeviceFlow(profileId) inside provideContent that pre-filters by WidgetDeviceKey, so the composition recomposes on visible state changes without firing on every BLE advertisement. Replace updateAll() with explicit per-GlanceId update() calls in WidgetManager, with a platform-id fallback when Glance returns no IDs.
2026-04-30 16:44:57 +02:00
darken c8f5ceda34 fix(monitor): Auto-tune BLE scanner mode by foreground state and connections
Replaces the user-facing scanner mode setting with an automatic policy that picks LOW_LATENCY when a profile-paired device is connected, BALANCED in the foreground, and LOW_POWER in the background. The TroubleShooter scopes a temporary LOW_LATENCY override via a refcounted withTemporaryOverride block so overlapping callers stay correct.

Fixes a regression where the controller could block BLE scanning entirely if BLUETOOTH_CONNECT was missing or the HEADSET profile proxy stalled, and adds a reactive bondedDeviceAddresses flow so bond changes propagate without waiting for an unrelated input. Cleans up the now-dead scanner mode strings across all locales and unused ScannerMode fields.
2026-04-30 15:26:45 +02:00
darken e6dbd2d660 fix(reaction): Restore BLE-only autoplay and stop false reactions on app start
- Apply seenLastAt freshness to all unauthenticated BLE samples (worn and not-worn). The earlier scoping to not-worn-only collapsed the second worn sample for BLE-only autoplay confirmation, so the staged play never fired.

- Replace distinctUntilChangedBy with a manual filter so worn samples that need to reset an active pause debounce (count went up) can pass through even when the monitor key is otherwise identical.

- Skip BLE-only autoplay confirmation for trusted sources. With BLE_IRK_MATCH and AAP, autoplay now fires on the first not-worn -> worn transition, mirroring the pause-debounce skip on the same sources.

- Skip the reaction entirely when the previous emission had no live evidence (NO_LIVE_BLE). Prevents app-process-start from synthesising a fake not-worn -> worn transition and firing autoplay while the user is already wearing the pods. Same guard handles mid-session BLE gap recoveries.

- Add MonitorFlowTests covering process-start-worn, genuine-insertion-after-startup, mid-session BLE-gap recovery, IRK-matched immediate autoplay, BLE-only autoplay confirmation, 3-sample pause debounce, and rebound-tolerated debounce reset.
2026-04-29 18:14:51 +02:00
darken 228d7d1f0c fix(reaction): Tighten auto-pause debounce after review findings
- Commit pending pause when a trusted source (AAP / BLE_IRK_MATCH) corroborates the not-worn condition mid-debounce, instead of dropping pending silently.

- Scope debounceFreshness to not-worn samples only; identical both-in samples no longer pass distinctUntilChangedBy and can't accidentally trigger BLE-only auto-play confirmation.

- Add resetTolerance to PendingPauseDebounce so a single corrupt count-up advert no longer kills a legitimate pending pause; reorder reset checks so rawDecision.shouldPlay resets immediately.

- Drop bleKeyState from the INFO autoPause log; source already encodes trust without leaking key-configuration state to logcat.

- Add flow-level MonitorFlowTests verifying the distinctUntilChangedBy interaction with seenLastAt freshness, plus the #557-direction test (AAP-worn vs corrupt-BLE-not-worn) and rebound-tolerance test.

- Clarify in BLE_ANONYMOUS KDoc that the path is unreachable in production via DeviceMonitor.primaryDevice.
2026-04-29 18:14:51 +02:00
darken 67fead3589 fix(reaction): Debounce auto-pause for unauthenticated BLE sources
Classifies the ear-detection source (AAP / BLE_IRK_MATCH / BLE_PROFILE_FALLBACK / BLE_ANONYMOUS / NO_LIVE_BLE) and applies a 3-sample debounce only to unauthenticated BLE paths. AAP and IRK-authenticated BLE pass through unchanged.

Also tightens toEarDetectionState() to prefer AAP aggregate over BLE per-side bits whenever AAP EarDetection is present, and suppresses pause on NO_LIVE_BLE (cache-only state) to avoid firing without live evidence.
2026-04-29 18:14:51 +02:00
darken d0aab583d2 fix(presscontrols): Auto-exit when device disconnects 2026-04-28 22:39:42 +02:00
darken 367c8d28ff chore: Drop stale app-common exclude from _config.yml
app-common module was merged into app/ in be8f4919; the exclude has been a no-op since.
2026-04-28 12:08:23 +02:00
darken b04e1fe628 chore(ci): Also rebuild Pages on main pushes for content edits
Adds push: [main] alongside workflow_dispatch so edits to README.md, _config.yml, _layouts, or the CHANGELOG.md template publish without a manual dispatch. The chain step in release-tag.yml still runs after release publish to guarantee the new release is in site.github.releases by the time Pages rebuilds — concurrency: cancel-in-progress: false serialises the two runs.

Adds an if: github.ref == 'refs/heads/main' guard on the deploy job so workflow_dispatch from a non-main branch builds for verification but doesn't deploy.
2026-04-28 12:08:23 +02:00
darken ea3ef1ff3b chore: Drop dead Gemfile excludes and ignore Jekyll artifacts
After moving Gemfile/Gemfile.lock to fastlane/, the - Gemfile / - Gemfile.lock entries in _config.yml's exclude list are no-ops; the parent fastlane exclude already covers everything inside.

.gitignore picks up _site/, .jekyll-cache/, vendor/bundle/ so local Jekyll runs don't leave tracked artifacts.
2026-04-28 12:08:23 +02:00
darken ff8b9cf6ed chore(ci): Chain Pages deploy from release-tag.yml and adopt org refinements
release: published events triggered by secrets.GITHUB_TOKEN do not start new workflow runs (only workflow_dispatch and repository_dispatch are exceptions). The Pages workflow's release: published trigger would never have fired in production since release-tag.yml's softprops/action-gh-release uses GITHUB_TOKEN to publish.

Fix: drop the release: published trigger and have release-tag.yml's release-github job explicitly run gh workflow run pages.yml --ref main after the release is created. release-github gains actions: write to authorize the dispatch.

Also adopts refinements from sibling org PRs (permission-pilot#356, bluemusic#220):

- Top-level permissions reduced to contents: read; pages: write and id-token: write moved to the deploy job only (least privilege)

- JEKYLL_GITHUB_TOKEN on the build step so jekyll-github-metadata authenticates when fetching site.github.releases

- Sanity-check step (test -f _site/index.html && _site/CNAME) fails fast if Jekyll produced nothing

- Explicit upload-pages-artifact path: ./_site matches the build's destination

- Verify fastlane Bundler wiring step (bundle exec fastlane --version) lets workflow_dispatch dry_run=true exercise the relocated Gemfile before the next real release
2026-04-28 12:08:23 +02:00
darken 96bc3f5df7 chore(ci): Move Gemfile to fastlane/ for tidier layout
The root Gemfile only ever declared the fastlane gem and lived next to fastlane configuration anyway. Moving it under fastlane/ matches that ownership and keeps the repo root cleaner.

release-gplay job now sets BUNDLE_GEMFILE=fastlane/Gemfile and runs ruby/setup-ruby with working-directory=fastlane so bundler-cache resolves the moved Gemfile. fastlane lanes still run from the repo root.
2026-04-28 12:08:23 +02:00
darken 1891a6d73a chore(ci): Migrate Pages to custom workflow with Node 24 actions
Replaces the auto pages-build-deployment (which still uses Node-20 actions/checkout@v4 and actions/upload-artifact@v4) with a custom workflow using configure-pages@v6, jekyll-build-pages@v1.0.13, upload-pages-artifact@v5, deploy-pages@v5.

Triggered by release publication so the changelog Liquid template (which reads site.github.releases) only rebuilds when a release actually exists. workflow_dispatch is kept for manual rebuilds when debugging Pages content.
2026-04-28 12:08:23 +02:00
darken 698415237d chore(ci): Drop unused Android build-cache step 2026-04-28 12:08:23 +02:00
darken 7ef672a11f chore(ci): Bump action-gh-release to v3.0.0 (Node 24) 2026-04-28 12:08:23 +02:00
darken f67e8aa7d1 fix: Harden foreground service exception handler 2026-04-25 23:40:10 +02:00
darken bcb4bdff08 fix(overview): Prevent crash from duplicate LazyList keys 2026-04-25 23:39:59 +02:00
838 changed files with 22183 additions and 4200 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
View File
@@ -43,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
- `pull-requests.md` — PR title and description conventions - `pull-requests.md` — PR title and description conventions
- `agent-instructions.md` — Sub-agent delegation and critical thinking - `agent-instructions.md` — Sub-agent delegation and critical thinking
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens - `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
+78
View File
@@ -0,0 +1,78 @@
# Release Process
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
## Dispatch
```bash
# Plan only — no commit, no tag, no push.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Real cut.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
## Inputs
| Input | Default | Notes |
|---|---|---|
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
| `dry_run` | `true` | Default is plan-only. |
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
## Local
```bash
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
./tools/release/bump.sh --mode=check
bats tools/release/bump.bats
```
## Channel mapping
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|---|---|---|---|---|---|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
## Rollback
| Stage reached | Steps |
|---|---|
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
## Auth setup
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
- The main-branch ruleset (PR + status check requirements)
- The tag ruleset (creation restriction on `v*`)
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
## Defense in depth
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
## Stuck-dispatch recovery
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
+30 -10
View File
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script | | `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure | | `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commit policy
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
## Commands ## Commands
```bash ```bash
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE. # Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup. # Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
./fastlane/generate_screenshots.sh --smoke ./fastlane/generate_screenshots.sh --smoke
# Full run — all 68 locales. Use only when intending to upload to Play Store
# (the non-smoke output is .gitignored and should not be committed).
./fastlane/generate_screenshots.sh
# Copy into fastlane directories (run after generate) # Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh ./fastlane/copy_screenshots.sh
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it 2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP` 3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
4. Update the expected count in `generate_screenshots.sh` (composables per locale) 4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh` 5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
## Removing or Renaming a Screenshot ## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping 1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh` 2. Run `generate_screenshots.sh --smoke`
3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store 3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## After UI Changes ## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate: When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
```bash ```bash
./fastlane/generate_screenshots.sh ./fastlane/generate_screenshots.sh --smoke
./fastlane/copy_screenshots.sh --clean ./fastlane/copy_screenshots.sh --clean
``` ```
## Refreshing all locales on Play Store
Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
./fastlane/copy_screenshots.sh --clean
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
```
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
## Technical Notes ## Technical Notes
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image) - Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
+1 -10
View File
@@ -30,13 +30,4 @@ runs:
~/.gradle/caches ~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }} key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle-caches- ${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.android/build-cache
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-build-cache-
+27 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on: on:
push: push:
branches: [ main ] branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request: pull_request:
branches: [ main ] branches: [ main ]
@@ -78,4 +81,27 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Validate metadata lengths - name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh run: bash fastlane/check_metadata_length.sh
check-release-tooling:
name: Release tooling
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Install bats and shellcheck
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Shellcheck bump.sh
run: shellcheck tools/release/bump.sh
- name: Run bump.sh unit tests
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
@@ -4,6 +4,9 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore:
- VERSION
- version.properties
pull_request: pull_request:
branches: branches:
- main - main
+66
View File
@@ -0,0 +1,66 @@
name: Deploy GitHub Pages
on:
push:
branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build Jekyll site
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
with:
source: ./
destination: ./_site
env:
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
- name: Sanity-check Jekyll output
run: test -f _site/index.html && test -f _site/CNAME
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
with:
path: ./_site
deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
+235
View File
@@ -0,0 +1,235 @@
name: Release prepare
on:
workflow_dispatch:
inputs:
bump_kind:
description: 'How to bump the version (ignored if version_override is set)'
type: choice
options: [build, patch, minor, major]
default: build
version_type:
description: 'Channel for the new version (keep-current preserves current type)'
type: choice
options: [keep-current, rc, beta]
default: keep-current
version_override:
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
type: string
default: ''
expected_current:
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
type: string
default: ''
dry_run:
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-prepare-main
cancel-in-progress: false
jobs:
compute-and-validate:
name: Compute and validate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
new_name: ${{ steps.plan.outputs.new_name }}
new_code: ${{ steps.plan.outputs.new_code }}
current_name: ${{ steps.plan.outputs.current_name }}
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
steps:
- name: Guard ref must be main
run: |
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
exit 1
fi
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Compute and validate
id: plan
run: |
set -euo pipefail
args=("--mode=plan")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
fi
./tools/release/bump.sh "${args[@]}" | tee plan.txt
{
grep -E '^current_name=' plan.txt
grep -E '^new_name=' plan.txt
grep -E '^new_code=' plan.txt
} >> "$GITHUB_OUTPUT"
- name: Tag collision check (local + remote)
env:
NEW_NAME: ${{ steps.plan.outputs.new_name }}
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Write step summary
env:
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
NEW_NAME: ${{ steps.plan.outputs.new_name }}
NEW_CODE: ${{ steps.plan.outputs.new_code }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
{
echo "## Release plan"
echo
echo "| | |"
echo "|---|---|"
echo "| Current | \`${CURRENT_NAME}\` |"
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Dry run | \`${DRY_RUN}\` |"
echo
echo "### bump.sh output"
echo
echo '```'
cat plan.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
push-and-dispatch:
name: Push and dispatch
needs: compute-and-validate
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-22.04
permissions:
contents: read
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
steps:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Checkout main with credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- name: Verify state still matches plan from Job 1
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Apply bump
run: |
set -euo pipefail
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
./tools/release/bump.sh "${args[@]}"
- name: Verify post-write state matches plan
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
- name: Configure git identity
env:
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
run: |
git config user.name "${BOT_USER_NAME}"
git config user.email "${BOT_USER_EMAIL}"
- name: Commit and tag
run: |
set -euo pipefail
git add version.properties VERSION
git commit -m "Release: ${NEW_NAME}"
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
- name: Atomic push (commit + tag)
run: |
set -euo pipefail
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
- name: Write step summary
run: |
set -euo pipefail
{
echo "## Released"
echo
echo "| | |"
echo "|---|---|"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Version code | \`${NEW_CODE}\` |"
echo "| Bump commit | on \`main\` |"
echo "| Downstream | dispatched \`release-tag.yml\` |"
echo
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
} >> "$GITHUB_STEP_SUMMARY"
+58 -2
View File
@@ -14,11 +14,54 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs: jobs:
validate-tag:
name: Validate tag
runs-on: ubuntu-22.04
steps:
- name: Check tag-name format
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
echo "Releases must be cut via the 'Release prepare' workflow." >&2
exit 1
fi
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Verify version.properties matches tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Strip leading 'v' to get the bare version name.
tag_name="${REF_NAME#v}"
# bump.sh in check mode emits current_name=...
parsed=$(./tools/release/bump.sh --mode=check)
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
if [[ "$current_name" != "$tag_name" ]]; then
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
exit 1
fi
release-github: release-github:
needs: validate-tag
name: Create GitHub release name: Create GitHub release
permissions: permissions:
contents: write contents: write
actions: write
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
environment: foss-production environment: foss-production
steps: steps:
@@ -61,7 +104,7 @@ jobs:
- name: Create pre-release - name: Create pre-release
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with: with:
prerelease: true prerelease: true
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
@@ -73,7 +116,7 @@ jobs:
- name: Create release - name: Create release
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)" if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with: with:
prerelease: false prerelease: false
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
@@ -83,10 +126,19 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger GitHub Pages deployment
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run pages.yml --ref main
release-gplay: release-gplay:
needs: validate-tag
name: Create Google Play release name: Create Google Play release
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
environment: gplay-production environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps: steps:
- name: Decode Keystore - name: Decode Keystore
env: env:
@@ -122,6 +174,10 @@ jobs:
with: with:
ruby-version: 3.3.6 ruby-version: 3.3.6
bundler-cache: true bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
- name: Assemble beta and upload to Google Play - name: Assemble beta and upload to Google Play
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+14 -1
View File
@@ -17,5 +17,18 @@
.kotlin .kotlin
# Screenshot test reference images (ephemeral, regenerated on demand) # Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/ app/src/screenshotTest*/reference/
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
.codex .codex
protocol-research/ protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
+1 -2
View File
@@ -81,8 +81,7 @@ Currently supported models:
## Screenshots ## 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://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">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## Thanks to ## Thanks to
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -1 +1 @@
5.1.1-rc0 50101000 5.2.1-rc0 50201000
-3
View File
@@ -20,9 +20,6 @@ exclude:
- buildSrc - buildSrc
- gradle/wrapper - gradle/wrapper
- fastlane - fastlane
- Gemfile
- Gemfile.lock
- crowdin* - crowdin*
- app - app
- app-common
- CONTRIBUTING.md - CONTRIBUTING.md
+1
View File
@@ -130,6 +130,7 @@ android {
//noinspection WrongGradleMethod //noinspection WrongGradleMethod
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
maxHeapSize = "4g"
} }
} }
} }
@@ -14,17 +14,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel 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.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme 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.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel import eu.darken.capod.profiles.ui.DeviceManagerViewModel
@@ -37,17 +41,49 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
internal const val DS = "spec:width=1080px,height=2400px,dpi=428" internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@Composable @Composable
internal fun DashboardContent() = PreviewWrapper { internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
val devices = if (showAap) {
listOf(
MockPodDataProvider.dualPodMonitoredWithAap(),
MockPodDataProvider.singlePodMonitoredWithAap(),
MockPodDataProvider.unknownMonitored(),
)
} else {
listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
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( OverviewScreen(
state = OverviewViewModel.State( state = OverviewViewModel.State(
now = MOCK_NOW, now = MOCK_NOW,
permissions = emptySet(), permissions = emptySet(),
devices = listOf( devices = devices,
MockPodDataProvider.dualPodMonitoredMixed(), isDebug = false,
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isBluetoothEnabled = true, isBluetoothEnabled = true,
profiles = listOf( profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2), MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -55,6 +91,7 @@ internal fun DashboardContent() = PreviewWrapper {
), ),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true), upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false, showUnmatchedDevices = false,
batteryEstimates = estimates,
), ),
onRequestPermission = {}, onRequestPermission = {},
onBluetoothSettings = {}, onBluetoothSettings = {},
@@ -83,19 +120,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
@Composable @Composable
internal fun AddProfileContent() = PreviewWrapper { internal fun AddProfileContent() = PreviewWrapper {
val pairedAirPods = BluetoothDevice2(
address = "AA:BB:CC:DD:EE:FF",
name = "AirPods Pro",
seenFirstAt = MOCK_NOW,
)
val bondedItems = listOf(
DeviceProfileCreationViewModel.BondedDeviceItem(
device = pairedAirPods,
claimedByProfile = null,
),
DeviceProfileCreationViewModel.BondedDeviceItem(
device = BluetoothDevice2(
address = "11:22:33:44:55:66",
name = "Living Room TV",
seenFirstAt = MOCK_NOW,
),
claimedByProfile = null,
),
)
DeviceProfileCreationScreen( DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State( state = DeviceProfileCreationViewModel.State(
isEditMode = false, isEditMode = false,
name = "", name = "My AirPods Pro",
nameError = null, nameError = null,
selectedModel = null, selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN }, availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null, identityKey = null,
encryptionKey = null, encryptionKey = null,
selectedDevice = null, selectedDevice = pairedAirPods,
bondedDeviceItems = emptyList(), bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f, minimumSignalQuality = 0.15f,
canSave = false, canSave = true,
), ),
onBack = {}, onBack = {},
onSave = {}, onSave = {},
@@ -200,7 +256,7 @@ internal fun HomescreenWidgetContent() {
bgColor = MaterialTheme.colorScheme.surface.toArgb(), bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(), textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(), iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
), ).withLocalizedPreviewEstimates(LocalContext.current),
) )
} }
} }
@@ -212,7 +268,7 @@ private fun PreviewDashboardLight() = DashboardContent()
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true) @Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable @Composable
private fun PreviewDashboardDark() = DashboardContent() private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true) @Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable @Composable
+7 -7
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="foss_upgrade_donate_label">تبرُّع</string> <string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string> <string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string> <string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string> <string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string> <string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string> <string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string> <string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string> <string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
</resources> </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="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_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_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_subtitle">Bez reklamām. Bez izsekoš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_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
<string name="upgrade_badge_label">FOSS</string> <string name="upgrade_badge_label">FOSS</string>
</resources> </resources>
+8 -8
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string> <string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</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_label">خطأ في جوجل بلاي</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string> <string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string> <string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string> <string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
<string name="upgrade_restore_action">استعادة المُشتَرَى</string> <string name="upgrade_restore_action">استعادة عملية الشراء</string>
<string name="upgrade_badge_label">احترافي</string> <string name="upgrade_badge_label">النسخة الاحترافية</string>
</resources> </resources>
+3 -3
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string> <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_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_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_restore_action">Atjaunot pirkumu</string>
<string name="upgrade_badge_label">Pro</string> <string name="upgrade_badge_label">Pro</string>
</resources> </resources>
+20 -1
View File
@@ -43,6 +43,11 @@
android:name="android.hardware.bluetooth_le" android:name="android.hardware.bluetooth_le"
android:required="true" /> android:required="true" />
<!-- Package visibility (API 30+): resolving the MIUI/HyperOS autostart settings screen. -->
<queries>
<package android:name="com.miui.securitycenter" />
</queries>
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
@@ -141,6 +146,20 @@
android:name=".monitor.core.worker.MonitorService" android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice" android:foregroundServiceType="connectedDevice"
android:exported="false" /> android:exported="false" />
<service
android:name=".main.ui.tile.AncTileService"
android:exported="true"
android:icon="@drawable/ic_anc_on"
android:label="@string/tile_anc_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="false" />
</service>
</application> </application>
</manifest> </manifest>
+28 -28
View File
@@ -1,17 +1,13 @@
package eu.darken.capod package eu.darken.capod
import android.app.Application import android.app.Application
import android.os.Looper
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.LogCatLogger import eu.darken.capod.common.debug.logging.LogCatLogger
import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE 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 import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.common.flow.throttleLatest
@@ -19,10 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor 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 eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -30,7 +32,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp @HiltAndroidApp
open class App : Application() { open class App : Application() {
@@ -39,25 +40,26 @@ open class App : Application() {
@Inject lateinit var deviceMonitor: DeviceMonitor @Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager @Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo @Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject @AppScope lateinit var appScope: CoroutineScope @Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger()) if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
var foregroundExceptionHandled = false
val oldHandler = Thread.getDefaultUncaughtExceptionHandler() val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler(
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) { CapodUncaughtExceptionHandler(
foregroundExceptionHandled = true previousHandler = oldHandler,
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" } cancelBeforeDelegate = { throwable ->
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable) // Best-effort shutdown: the system handler may terminate the process immediately,
Looper.loop() // but cancellation can still close sockets if it gets a scheduling window.
return@setDefaultUncaughtExceptionHandler if (::appScope.isInitialized) {
} appScope.cancel(CancellationException("Uncaught exception", throwable))
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1) },
} )
)
autoReporting.setup(this) autoReporting.setup(this)
@@ -65,8 +67,15 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() } appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles() combine(
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } } 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) .throttleLatest(1000)
.onEach { .onEach {
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." } log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
@@ -86,14 +95,5 @@ open class App : Application() {
companion object { companion object {
internal val TAG = logTag("CAP") internal val TAG = logTag("CAP")
private fun Throwable.isForegroundServiceTimingException(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
current = current.cause
}
return false
}
} }
} }
@@ -0,0 +1,73 @@
package eu.darken.capod
import android.os.Looper
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.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.system.exitProcess
internal class CapodUncaughtExceptionHandler(
private val previousHandler: Thread.UncaughtExceptionHandler?,
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
private val loopMainThread: () -> Unit = { Looper.loop() },
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
Bugs.report(
tag = App.TAG,
message = "Foreground service timing exception suppressed",
exception = throwable,
)
},
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
private val exit: (Int) -> Unit = { exitProcess(it) },
) : Thread.UncaughtExceptionHandler {
private val foregroundExceptionHandled = AtomicBoolean(false)
override fun uncaughtException(thread: Thread, throwable: Throwable) {
if (shouldSuppress(thread, throwable)) {
runCatching {
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
reportForegroundServiceTimingException(throwable)
}
val loopResult = runCatching { loopMainThread() }
if (loopResult.isSuccess) return
val loopFailure = loopResult.exceptionOrNull()!!
runCatching {
log(App.TAG, ERROR) {
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
}
}
delegate(thread, loopFailure)
return
}
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
delegate(thread, throwable)
}
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
return throwable.isForegroundServiceTimingException() &&
isMainThread &&
foregroundExceptionHandled.compareAndSet(false, true)
}
private fun delegate(thread: Thread, throwable: Throwable) {
runCatching { cancelBeforeDelegate(throwable) }
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
}
}
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
current = current.cause
}
return false
}
@@ -0,0 +1,85 @@
package eu.darken.capod.common
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppForegroundState @Inject constructor(
@AppScope appScope: CoroutineScope,
dispatcherProvider: DispatcherProvider,
) {
val isForeground: StateFlow<Boolean> = callbackFlow {
val lifecycle = runCatching { ProcessLifecycleOwner.get().lifecycle }
.getOrElse {
log(TAG, WARN) { "Failed to access process lifecycle, assuming background: ${it.asLog()}" }
trySend(false)
close()
return@callbackFlow
}
trySend(safeCurrentForegroundState())
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> trySend(true)
Lifecycle.Event.ON_STOP -> trySend(false)
else -> Unit
}
}
runCatching { lifecycle.addObserver(observer) }
.onFailure {
log(TAG, WARN) { "Failed to observe process lifecycle, assuming background: ${it.asLog()}" }
trySend(false)
close()
}
awaitClose {
runCatching { lifecycle.removeObserver(observer) }
.onFailure { log(TAG, WARN) { "Failed to remove process lifecycle observer: ${it.asLog()}" } }
}
}
.flowOn(dispatcherProvider.MainImmediate)
.catch {
log(TAG, WARN) { "Foreground state flow failed, assuming background: ${it.asLog()}" }
emit(false)
}
.distinctUntilChanged()
.onEach { log(TAG) { "isForeground=$it" } }
.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = safeCurrentForegroundState(),
)
private fun safeCurrentForegroundState(): Boolean = runCatching {
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}.getOrElse {
log(TAG, WARN) { "Failed to read process lifecycle state, assuming background: ${it.asLog()}" }
false
}
companion object {
private val TAG = logTag("App", "ForegroundState")
}
}
@@ -1,8 +1,11 @@
package eu.darken.capod.common package eu.darken.capod.common
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.view.KeyEvent import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO 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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
private val audioManager: AudioManager, private val audioManager: AudioManager,
private val timeSource: TimeSource, 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 val isPlaying: Boolean
get() = audioManager.isMusicActive get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime() get() = capPaused
suspend fun sendPlay() { suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" } log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) { if (audioManager.isMusicActive && !capPaused) {
log(TAG, INFO) { "Music is already playing, not sending play" } log(TAG, INFO) { "Music is already playing, not sending play" }
return return
} }
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY) sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause() capPaused = false
} }
/** /**
* Dispatches a MEDIA_PAUSE key event if music is currently playing. * Dispatches a MEDIA_PAUSE key event if music is currently playing.
* *
* Returns `true` when a key event was actually dispatched (and the 15-second * @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because * pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
* nothing was playing. Callers that need to distinguish "we actually paused" from "there * flow. When `false` (default), the dispatched pause clears any pending auto-resume —
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and * this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
* cooldown on a real pause — should branch on the return value rather than checking * is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
* [isPlaying] themselves to avoid a check-then-act race with the audio system. * 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 { suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
log(TAG, INFO) { "sendPause()" } log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
if (!audioManager.isMusicActive) { if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" } log(TAG, INFO) { "Music is not playing, not sending pause" }
return false 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) sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true 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() { suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" } log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) { if (capPaused) {
sendPlay() sendPlay()
return return
} }
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
) )
} }
private fun markRecentCapPause() { /** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS 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() { /** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
capPauseExpiryElapsedRealtime = 0L 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 { companion object {
private val TAG = logTag("MediaControl") 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 } .sortedBy { it.key }
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" } .joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
.ifEmpty { "-" } .ifEmpty { "-" }
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]" return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
} }
@JvmName("logBleScanResultCollectionSummary") @JvmName("logBleScanResultCollectionSummary")
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
.sorted() .sorted()
.joinToString(separator = ",") .joinToString(separator = ",")
.ifEmpty { "-" } .ifEmpty { "-" }
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]" return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
} }
@JvmName("logFrameworkScanResultCollectionSummary") @JvmName("logFrameworkScanResultCollectionSummary")
@@ -16,6 +16,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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,7 +33,6 @@ import javax.inject.Singleton
class BleScanner @Inject constructor( class BleScanner @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder, private val scanResultForwarder: BleScanResultForwarder,
private val timeSource: TimeSource, private val timeSource: TimeSource,
) { ) {
@@ -113,22 +113,7 @@ class BleScanner @Inject constructor(
null null
} }
val flushJob = if (!disableDirectScanCallback) { var flushJob: Job? = null
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
val filterList = when { val filterList = when {
useOffloadedFiltering -> filters.toList() useOffloadedFiltering -> filters.toList()
@@ -167,31 +152,59 @@ class BleScanner @Inject constructor(
setReportDelay(delay) setReportDelay(delay)
}.build() }.build()
if (disableDirectScanCallback) { try {
val callbackIntent = createStartIntent() if (disableDirectScanCallback) {
log(TAG) { val callbackIntent = createStartIntent()
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)" log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
try {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
scanner.flushPendingScanResults(callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
close(e)
break
}
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} }
scanner.startScan(filterList, scanSettings, callbackIntent) } catch (e: SecurityException) {
} else { log(TAG, WARN) { "startScan() denied: ${e.message}" }
log(TAG) { forwarderConsumer?.cancel()
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)" close(e)
} return@callbackFlow
scanner.startScan(filterList, scanSettings, callback)
} }
awaitClose { awaitClose {
forwarderConsumer?.cancel() forwarderConsumer?.cancel()
flushJob?.cancel() flushJob?.cancel()
if (disableDirectScanCallback) { try {
scanner.stopScan(createStopIntent()) if (disableDirectScanCallback) {
} else { scanner.stopScan(createStopIntent())
scanner.stopScan(callback) } else {
scanner.stopScan(callback)
}
} catch (e: SecurityException) {
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
} }
log(TAG) { "BleScanner stopped" } log(TAG) { "BleScanner stopped" }
} }
} }
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy { private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply { Intent(context, BleScanResultReceiver::class.java).apply {
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
import java.time.Instant import java.time.Instant
data class BluetoothDevice2( data class BluetoothDevice2(
internal val internal: BluetoothDevice, val address: BluetoothAddress,
val name: String?,
val seenFirstAt: Instant, val seenFirstAt: Instant,
) { internal val internal: BluetoothDevice? = null,
val address: BluetoothAddress )
get() = internal.address
val name: String?
get() = internal.name
}
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import dagger.hilt.android.qualifiers.ApplicationContext 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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers 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 eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -255,6 +257,8 @@ class BluetoothManager2 @Inject constructor(
} }
.map { device -> .map { device ->
BluetoothDevice2( BluetoothDevice2(
address = device.address,
name = device.name,
internal = device, internal = device,
seenFirstAt = seenDevicesLock.withLock { seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run { seenDevicesCache[device.address] ?: run {
@@ -304,6 +308,8 @@ class BluetoothManager2 @Inject constructor(
val wrappedDevices = rawDevices.map { device -> val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2( BluetoothDevice2(
address = device.address,
name = device.name,
internal = device, internal = device,
seenFirstAt = seenDevicesLock.withLock { seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run { seenDevicesCache[device.address] ?: run {
@@ -318,8 +324,73 @@ class BluetoothManager2 @Inject constructor(
emit(wrappedDevices) emit(wrappedDevices)
} }
private var _isNudgeAvailable: Boolean = true @android.annotation.SuppressLint("MissingPermission")
val isNudgeAvailable: Boolean get() = _isNudgeAvailable private fun queryBondedAddresses(): Set<BluetoothAddress> =
adapter?.bondedDevices?.map { it.address }?.toSet() ?: emptySet()
val bondedDeviceAddresses: Flow<Set<BluetoothAddress>> = callbackFlow {
fun sendBondedAddresses(): Boolean = try {
trySend(queryBondedAddresses())
true
} catch (e: Exception) {
log(TAG, WARN) { "Error querying bonded device addresses: $e" }
trySend(emptySet())
close(e)
false
}
if (!sendBondedAddresses()) return@callbackFlow
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
sendBondedAddresses()
}
}
try {
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to register bond-state receiver: $e" }
close(e)
return@callbackFlow
}
awaitClose {
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
log(TAG, WARN) { "Error unregistering bond-state receiver: $e" }
}
}
}
.retryWhen { cause, attempt ->
log(TAG, WARN) { "bondedDeviceAddresses Flow failed (attempt ${attempt + 1}): $cause" }
when {
cause is SecurityException -> {
delay(3_000L)
true
}
attempt < 3 -> {
delay(1000 * (attempt + 1))
true
}
else -> false
}
}
.catch { e ->
log(TAG, ERROR) { "bondedDeviceAddresses Flow failed after retries: $e" }
emit(emptySet())
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "bondedDeviceAddresses" }
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000L,
replayExpirationMillis = 0L,
),
initialValue = emptySet(),
)
/** /**
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth * Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
@@ -340,9 +411,10 @@ class BluetoothManager2 @Inject constructor(
@android.annotation.SuppressLint("MissingPermission") @android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean { fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try { return try {
val target = device.internal ?: return false
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java) val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true } .apply { isAccessible = true }
val result = method.invoke(device.internal, alias) as? Boolean ?: false val result = method.invoke(target, alias) as? Boolean ?: false
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" } log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result result
} catch (e: Exception) { } catch (e: Exception) {
@@ -352,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
} }
} }
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile -> suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
try { getBluetoothProfile().map { bluetoothProfile ->
log(TAG) { "Nudging Android connection to $device" } try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod( val target = device.internal ?: return@map NudgeAttemptResult.Rejected
"connect", BluetoothDevice::class.java val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
).apply { isAccessible = true } "connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" } log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
} catch (e: Exception) { } catch (e: Exception) {
val isSecurityException = e is SecurityException || val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException) (e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (isSecurityException) { if (!isSecurityException) {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" } Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
_isNudgeAvailable = false 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) }.first()
false
}
}.first()
companion object { companion object {
private val TAG = logTag("Bluetooth", "Manager2") 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
}
}
}
@@ -1,25 +1,11 @@
package eu.darken.capod.common.bluetooth package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class ScannerMode( enum class ScannerMode {
val identifier: String, @SerialName("scanner.mode.lowpower") LOW_POWER,
@StringRes val labelRes: Int @SerialName("scanner.mode.balanced") BALANCED,
) { @SerialName("scanner.mode.lowlatency") LOW_LATENCY,
@SerialName("scanner.mode.lowpower") LOW_POWER( }
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@SerialName("scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
}
@@ -180,18 +180,21 @@ object MockPodDataProvider {
fun dualPodMonitoredMixed(): PodDevice = PodDevice( fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed", profileId = "preview-dual-mixed",
label = "My AirPods Pro",
ble = airPodsProMixed(), ble = airPodsProMixed(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice( fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys", profileId = "preview-dual-keys",
label = "My AirPods Pro",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithAap(): PodDevice = PodDevice( fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap", profileId = "preview-dual-aap",
label = "My AirPods Pro",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = AapPodState( aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
@@ -209,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. */ /** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice( fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
profileId = "preview-dual-reactions", profileId = "preview-dual-reactions",
@@ -227,12 +261,14 @@ object MockPodDataProvider {
fun singlePodMonitored(): PodDevice = PodDevice( fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single", profileId = "preview-single",
label = "AirPods Max",
ble = airPodsMax(), ble = airPodsMax(),
aap = null, aap = null,
) )
fun singlePodMonitoredWithAap(): PodDevice = PodDevice( fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap", profileId = "preview-single-aap",
label = "AirPods Max",
ble = airPodsMaxCharging(), ble = airPodsMaxCharging(),
aap = AapPodState( aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
@@ -454,7 +490,6 @@ private class MockSingleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn override val isBeingWorn: Boolean = _isBeingWorn
} }
@Suppress("PropertyName")
private class MockSingleAppleBlePodSnapshot( private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel, private val _model: PodModel,
private val _label: String, private val _label: String,
@@ -493,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn override val isBeingWorn: Boolean = _isBeingWorn
} }
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot( private class MockDualAppleBlePodSnapshot(
private val _model: PodModel, private val _model: PodModel,
private val _label: String, 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.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
) { ) {
val keyName: String get() = key.name val keyName: String get() = key.name
val flow: Flow<T> = dataStore.data.map { prefs -> val flow: Flow<T> = dataStore.data
reader(prefs[key]) .map { prefs -> prefs[key] }
} .distinctUntilChanged()
.map { raw -> reader(raw) }
data class Updated<T>(val old: T, val new: T) data class Updated<T>(val old: T, val new: T)
@@ -5,23 +5,41 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE 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.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog 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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.MutableStateFlow
object Bugs { object Bugs {
var reporter: AutomaticBugReporter? = null 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( fun report(
tag: String, tag: String,
message: String, message: String,
exception: Throwable exception: Throwable
) { ) {
log(TAG, VERBOSE) { "Reporting $exception" } runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
log(tag, ERROR) { "$message\n${exception.asLog()}" } runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
reporter?.notify(exception) ?: run { val bugReporter = reporter
log(TAG, WARN) { "Bug tracking not initialized yet." } if (bugReporter == null) {
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
return
} }
runCatching { bugReporter.notify(exception) }
.onFailure { failure ->
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
}
} }
private val TAG = logTag("Bugs") private val TAG = logTag("Bugs")
} }
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
// Reporting is opt-out for gplay, and opt-in for github builds // Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY 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)
} }
@@ -61,16 +61,19 @@ object Logging {
message: String message: String
) { ) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() } val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot snapshot.forEach {
.filter { it.isLoggable(priority) } val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
.forEach { if (isLoggable) {
it.log( runCatching {
priority = priority, it.log(
tag = tag, priority = priority,
metaData = metaData, tag = tag,
message = message metaData = metaData,
) message = message
)
}
} }
}
} }
fun clearAll() { fun clearAll() {
@@ -110,14 +113,28 @@ inline fun log(
} }
} }
fun Throwable.asLog(): String { fun Throwable.asLog(): String = runCatching {
val stringWriter = StringWriter(256) val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false) val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter) printStackTrace(printWriter)
printWriter.flush() printWriter.flush()
return stringWriter.toString() stringWriter.toString()
}.getOrElse { renderFailure ->
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
} }
fun Throwable.asLogSummary(): String {
val throwableClass = javaClass.name
val throwableMessage = safeMessage()
return if (throwableMessage.isNullOrBlank()) {
throwableClass
} else {
"$throwableClass: $throwableMessage"
}
}
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
@PublishedApi @PublishedApi
internal fun Any.logTagViaCallSite(): String { internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java val javaClass = this::class.java
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider 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.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO 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.Logging.Priority.WARN
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
} }
} }
.launchIn(appScope) .launchIn(appScope)
internalState.flow
.onEach { Bugs.isDebug.value = it.isRecording }
.launchIn(appScope)
} }
private fun createSessionDir(): File { private fun createSessionDir(): File {
@@ -36,9 +36,6 @@ object Nav {
@Serializable @Serializable
data object General : Settings data object General : Settings
@Serializable
data object Debug : Settings
@Serializable @Serializable
data object Support : Settings data object Support : Settings
@@ -1,11 +1,10 @@
package eu.darken.capod.common.permissions package eu.darken.capod.common.permissions
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel import eu.darken.capod.common.withinApiLevel
@@ -18,7 +17,7 @@ enum class Permission(
val permissionId: String, val permissionId: String,
val isScanBlocking: Boolean = false, val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = { val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
}, },
) { ) {
BLUETOOTH( BLUETOOTH(
@@ -87,4 +86,4 @@ enum class Permission(
fun Permission.isRequired(context: Context): Boolean = when { fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false !withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context) else -> !isGranted(context)
} }
@@ -9,13 +9,14 @@ import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.datastore.createValue import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.common.theming.ThemeColor import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeStyle import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.monitor.core.MonitorSessionMark
import eu.darken.capod.pods.core.apple.PodModel 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.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
@@ -37,15 +38,11 @@ class GeneralSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore 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 useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
val keepConnectedNotificationAfterDisconnect = val keepConnectedNotificationAfterDisconnect =
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false) dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f) val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>( val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
@@ -77,8 +74,37 @@ class GeneralSettings @Inject constructor(
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false) val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", 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 isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
/**
* Identity of the currently running monitor session, null while it isn't running.
* Set atomically on monitor start, cleared on clean [android.app.Service.onDestroy] — an OS
* force-stop skips onDestroy, so a non-null value at the next monitor start is evidence of an
* unclean death (see MonitorKillDetector).
*/
val monitorSessionMark = dataStore.createValue<MonitorSessionMark?>(
"core.monitor.health.session", null, json, onErrorFallbackToDefault = true,
)
/** Timestamp of the newest [android.app.ApplicationExitInfo] record already processed. */
val exitInfoWatermark = dataStore.createValue("core.monitor.health.exitinfo.watermark", 0L)
/** Timestamp of the most recent detected OS kill of the monitor (0 = none). */
val lastOsKillAt = dataStore.createValue("core.monitor.health.oskill.last", 0L)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
/** When the "your phone stopped CAPod" hint was dismissed; re-shown only for newer kills. */
val osKillHintDismissedAt = dataStore.createValue("ui.hint.oskill.dismissed", 0L)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue( val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json, "core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -1,21 +1,13 @@
package eu.darken.capod.main.core package eu.darken.capod.main.core
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class MonitorMode( enum class MonitorMode(
@StringRes val labelRes: Int val intensity: Int,
) { ) {
@SerialName("monitor.mode.manual") MANUAL( @SerialName("monitor.mode.manual") MANUAL(intensity = 0),
R.string.settings_monitor_mode_manual_label @SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
), @SerialName("monitor.mode.always") ALWAYS(intensity = 2),
@SerialName("monitor.mode.automatic") AUTOMATIC( }
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@@ -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.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired 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.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -21,7 +22,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class PermissionTool @Inject constructor( class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings, private val monitorModeResolver: MonitorModeResolver,
private val profilesRepo: DeviceProfilesRepo, private val profilesRepo: DeviceProfilesRepo,
) { ) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID()) private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
val missingPermissions: Flow<Set<Permission>> = combine( val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger, permissionCheckTrigger,
generalSettings.monitorMode.flow, monitorModeResolver.effectiveMode,
anyPopupEnabled, anyPopupEnabled,
) { _, monitorMode, showPopUp -> ) { _, monitorMode, showPopUp ->
Permission.entries Permission.entries
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS } .filter { isApplicable(it, monitorMode, showPopUp) }
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
.filter { it.isRequired(context) } .filter { it.isRequired(context) }
.toSet() .toSet()
} }
@@ -61,5 +60,26 @@ class PermissionTool @Inject constructor(
companion object { companion object {
private val TAG = logTag("PermissionTool") 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] whenever the monitor service is expected to
* run unattended (AUTOMATIC and ALWAYS) — those users depend on a long-lived foreground
* service that aggressive vendor battery management may otherwise kill
* - [Permission.ACCESS_BACKGROUND_LOCATION] only when always-on scanning is needed
* - [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 -> monitorMode != MonitorMode.MANUAL
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
else -> true
}
} }
} }
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.navigation.LocalNavigationController import eu.darken.capod.common.navigation.LocalNavigationController
import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationController import eu.darken.capod.common.navigation.NavigationController
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
@Inject lateinit var popUpWindow: PopUpWindow @Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var upgradeRepo: UpgradeRepo @Inject lateinit var upgradeRepo: UpgradeRepo
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
private val warmIntents = SingleEventFlow<Intent>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
navCtrl.setup(backStack) navCtrl.setup(backStack)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Cold-start intent (the activity's launching intent).
consumeUpgradeExtra(intent) consumeUpgradeExtra(intent)
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
// directly from onNewIntent) guarantees navCtrl.setup() has run.
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
} }
CapodTheme(state = themeState) { CapodTheme(state = themeState) {
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
consumeUpgradeExtra(intent) setIntent(intent)
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
warmIntents.tryEmit(intent)
} }
private fun consumeUpgradeExtra(intent: Intent?) { private fun consumeUpgradeExtra(intent: Intent?) {
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.components package eu.darken.capod.main.ui.components
import android.content.Context import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome import androidx.compose.material.icons.twotone.AutoAwesome
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
} }
@DrawableRes
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard 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.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.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard 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.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition 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.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -148,6 +151,8 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) }, onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) }, onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onConversationActionChange = { vm.setConversationAction(it) },
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) }, onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) }, onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) }, onToneVolumeChange = { vm.setToneVolume(it) },
@@ -167,13 +172,18 @@ fun DeviceSettingsScreenHost(
onOnePodModeChange = { vm.setOnePodMode(it) }, onOnePodModeChange = { vm.setOnePodMode(it) },
onAutoPlayChange = { vm.setAutoPlay(it) }, onAutoPlayChange = { vm.setAutoPlay(it) },
onAutoPauseChange = { vm.setAutoPause(it) }, onAutoPauseChange = { vm.setAutoPause(it) },
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
onAutoConnectChange = { vm.setAutoConnect(it) }, onAutoConnectChange = { vm.setAutoConnect(it) },
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) }, onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) }, onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) }, onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() }, onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
onChargedThresholdChange = { vm.setChargedThreshold(it) },
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
onOpenIssueTracker = { vm.openIssueTracker() }, onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() }, onOpenAapTracker = { vm.openAapCompatibilityTracker() },
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
) )
} }
@@ -186,6 +196,8 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {}, onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {}, onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {}, onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {}, onToneVolumeChange: (Int) -> Unit = {},
@@ -205,13 +217,18 @@ fun DeviceSettingsScreen(
onOnePodModeChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {},
onAutoPlayChange: (Boolean) -> Unit = {}, onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {}, onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {}, onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {}, onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {}, onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {}, onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {}, onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {}, onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {}, onOpenAapTracker: () -> Unit = {},
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
onResetBatteryEstimate: () -> Unit = {},
) { ) {
val device = state.device val device = state.device
val features = device?.model?.features val features = device?.model?.features
@@ -279,6 +296,23 @@ fun DeviceSettingsScreen(
info = info, info = info,
labels = rememberDeviceInfoDetailLabels(), labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) }, 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( DeviceInfoCard(
deviceInfo = device.deviceInfo, deviceInfo = device.deviceInfo,
@@ -339,22 +373,42 @@ fun DeviceSettingsScreen(
device = device, device = device,
features = features, features = features,
isPro = isPro, isPro = isPro,
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange, onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange, onAutoPauseChange = onAutoPauseChange,
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange, onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange, onConversationalAwarenessChange = onConversationalAwarenessChange,
onConversationActionChange = onConversationActionChange,
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
onSleepDetectionChange = onSleepDetectionChange, onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange, onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange, onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange, onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange, onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onFixMonitorMode = onFixMonitorMode,
onOpenIssueTracker = onOpenIssueTracker, 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 // Settings — only show when AAP is connected
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) { if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
@@ -386,7 +440,7 @@ fun DeviceSettingsScreen(
val showSoundSection = val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) || (features.hasPersonalizedVolume && personalizedVol != null) ||
(features.hasToneVolume && toneVol != null) || (features.hasToneVolume && toneVol != null) ||
features.hasMicrophoneMode (features.hasMicrophoneMode && device.microphoneMode != null)
if (showSoundSection) { if (showSoundSection) {
item("sound_section") { item("sound_section") {
SoundCard( SoundCard(
@@ -404,7 +458,7 @@ fun DeviceSettingsScreen(
} }
// ── Controls ───────────────────────────────── // ── Controls ─────────────────────────────────
val showControlsSection = features.hasStemConfig || val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) || (features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
(features.hasPressSpeed && device.pressSpeed != null) || (features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) || (features.hasPressHoldDuration && device.pressHoldDuration != null) ||
@@ -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 ─────────────────────────────── // ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { 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.TimeSource
import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2 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.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.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN 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.log
@@ -16,10 +18,14 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro 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.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor 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.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.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition 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 eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,7 +59,10 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo, private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo, 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 timeSource: TimeSource,
private val webpageTool: WebpageTool, private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
@@ -114,8 +125,10 @@ class DeviceSettingsViewModel @Inject constructor(
// The Bluetooth HEADSET profile lookup can be slow to produce its first value. // The Bluetooth HEADSET profile lookup can be slow to produce its first value.
// Seed this branch so the screen can render immediately after navigation. // Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) }, bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow, monitorModeResolver.effectiveMode,
profilesRepo.profiles, profilesRepo.profiles,
nudgeCapabilityStore.availability,
drainStore.profiles,
) { args -> ) { args ->
val device = args[1] as PodDevice? val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info val upgrade = args[2] as UpgradeRepo.Info
@@ -127,9 +140,13 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile> 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 } .firstOrNull { it.id == profileId }
?.stemActions val stemActions = appleProfile?.stemActions
val connectedAddresses = connectedDevices.map { it.address }.toSet() val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr -> val systemBtName = device?.address?.let { addr ->
try { try {
@@ -142,7 +159,7 @@ class DeviceSettingsViewModel @Inject constructor(
device = device, device = device,
now = timeSource.now(), now = timeSource.now(),
isPro = upgrade.isPro, isPro = upgrade.isPro,
isNudgeAvailable = bluetoothManager.isNudgeAvailable, isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing, isForceConnecting = forcing,
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true, isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode, monitorMode = monitorMode,
@@ -151,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) || (it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc) (it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true, } == 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() }.asLiveState()
@@ -176,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC, val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
val systemBluetoothName: String? = null, val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false, 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() val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
} }
@@ -206,18 +242,19 @@ class DeviceSettingsViewModel @Inject constructor(
events.tryEmit(Event.OpenBluetoothSettings) events.tryEmit(Event.OpenBluetoothSettings)
return@launch return@launch
} }
if (!bluetoothManager.isNudgeAvailable) { if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
events.tryEmit(Event.OpenBluetoothSettings) events.tryEmit(Event.OpenBluetoothSettings)
return@launch return@launch
} }
val accepted = try { val result = try {
bluetoothManager.nudgeConnection(bonded) bluetoothManager.nudgeConnection(bonded)
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" } log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
false NudgeAttemptResult.Rejected
} }
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" } log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
if (!accepted) { nudgeCapabilityStore.record(result)
if (result != NudgeAttemptResult.Accepted) {
events.tryEmit(Event.OpenBluetoothSettings) events.tryEmit(Event.OpenBluetoothSettings)
} }
} finally { } finally {
@@ -382,6 +419,15 @@ class DeviceSettingsViewModel @Inject constructor(
syncEarDetection(autoPause = enabled) 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 * Keeps the device-side Automatic Ear Detection setting in sync with the
* auto-play / auto-pause reaction toggles. When either reaction is active * auto-play / auto-pause reaction toggles. When either reaction is active
@@ -405,15 +451,6 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoConnect(enabled: Boolean) = launch { fun setAutoConnect(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoConnect($enabled)" } log(TAG, INFO) { "setAutoConnect($enabled)" }
updateProfileNow { it.copy(autoConnect = 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 { fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
@@ -431,14 +468,62 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) } proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
} }
fun setMonitorModeAutomatic() = launch { fun setNotifyWhenCharged(enabled: Boolean) {
log(TAG, INFO) { "setMonitorModeAutomatic()" } log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC) 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 { fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" } log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch val profileId = targetProfileId.value ?: return@launch
val device = deviceMonitor.getDeviceForProfile(profileId)
if (device?.isAapReady != true) {
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
return@launch
}
navTo(Nav.Main.PressControls(profileId = profileId)) navTo(Nav.Main.PressControls(profileId = profileId))
} }
@@ -2,41 +2,176 @@ package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull 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.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.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper 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.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem 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.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel 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 * The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention * battery health lead, then a divider, then the charging-side settings:
* used by every other boolean setting. * - 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 @Composable
internal fun BatteryCard( internal fun BatteryCard(
device: PodDevice, device: PodDevice,
features: PodModel.Features, features: PodModel.Features,
enabled: Boolean, isPro: Boolean,
chargeCapControlEnabled: Boolean,
estimateEnabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {}, onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onEstimateEnabledChange: (Boolean) -> Unit = {},
onResetEstimate: () -> Unit = {},
) { ) {
if (!features.hasDynamicEndOfCharge) return val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
val cap = device.dynamicEndOfCharge ?: return // 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)) { SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
SettingsSwitchItem( // Time remaining & battery health lead — the headline feature. Charging-side settings
icon = Icons.TwoTone.BatteryChargingFull, // (Apple's charge limit, the charged notification) follow below the divider.
title = stringResource(R.string.device_settings_charge_cap_label), if (showLiveControls) {
subtitle = stringResource(R.string.device_settings_charge_cap_description), SettingsSwitchItem(
checked = cap.enabled, icon = Icons.TwoTone.Schedule,
onCheckedChange = onDynamicEndOfChargeChange, title = stringResource(R.string.device_battery_estimate_toggle_label),
enabled = enabled, 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!! val device = state.device!!
BatteryCard( BatteryCard(
device = device, device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true), features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
enabled = true, isPro = true,
chargeCapControlEnabled = true,
estimateEnabled = true,
) )
} }
@@ -60,7 +197,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
val device = state.device!! val device = state.device!!
BatteryCard( BatteryCard(
device = device, device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true), features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
enabled = false, isPro = false,
chargeCapControlEnabled = false,
estimateEnabled = false,
) )
} }
@@ -29,10 +29,10 @@ internal fun ControlsCard(
val volSwipe = device.volumeSwipe val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength val volSwipeLen = device.volumeSwipeLength
val showPressControlsNav = features.hasStemConfig || val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
features.hasPressSpeed || (features.hasPressSpeed && device.pressSpeed != null) ||
features.hasPressHoldDuration || (features.hasPressHoldDuration && device.pressHoldDuration != null) ||
features.hasEndCallMuteMic (features.hasEndCallMuteMic && device.endCallMuteMic != null)
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) { SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
if (showPressControlsNav) { if (showPressControlsNav) {
@@ -18,6 +18,9 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
rightSerial = stringResource(R.string.device_settings_info_right_serial_label), rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label), leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_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( internal data class DeviceInfoDetailLabels(
@@ -31,14 +34,52 @@ internal data class DeviceInfoDetailLabels(
val rightSerial: String, val rightSerial: String,
val leftBonded: String, val leftBonded: String,
val rightBonded: 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( internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?, info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels, labels: DeviceInfoDetailLabels,
batteryHealth: BatteryHealthTexts? = null,
formatDate: (Instant) -> String, formatDate: (Instant) -> String,
): List<DeviceDetailItem> { ): 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 { return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let { info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it)) add(DeviceDetailItem.Single(labels.manufacturer, it))
@@ -82,5 +123,6 @@ internal fun buildDeviceInfoDetailItems(
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded)) leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded)) rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
} }
addAll(healthItems(batteryHealth, labels))
} }
} }
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
text = stringResource(R.string.press_controls_long_press_anc_cycle_info), text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
type = InfoBoxType.INFO, type = InfoBoxType.INFO,
action = { action = {
TextButton(onClick = onPressControlsClick) { TextButton(
onClick = onPressControlsClick,
enabled = enabled,
) {
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action)) Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
} }
}, },
@@ -4,12 +4,14 @@ import android.os.Build
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message 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.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.RecordVoiceOver
import androidx.compose.material.icons.twotone.Workspaces import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -18,6 +20,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem 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.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection 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.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.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting 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.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable @Composable
internal fun ReactionsCard( internal fun ReactionsCard(
device: PodDevice, device: PodDevice,
features: PodModel.Features, features: PodModel.Features,
isPro: Boolean, isPro: Boolean,
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {}, onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {}, onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {}, onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {}, onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {}, onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {}, onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {}, onOpenIssueTracker: () -> Unit = {},
) { ) {
val reactions = device.reactions val reactions = device.reactions
val enabled = device.isAapReady val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) } var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) { SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) { if (features.hasEarDetection) {
@@ -73,6 +82,16 @@ internal fun ReactionsCard(
onCheckedChange = onAutoPlayChange, onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro, 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( SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle, icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label), title = stringResource(R.string.settings_autopause_label),
@@ -117,20 +136,49 @@ internal fun ReactionsCard(
} }
if (device.isAapConnected) { if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness val convAwareness = device.conversationalAwareness
val hasAnyAapReaction = val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
(features.hasConversationAwareness && convAwareness != null) || val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
features.hasSleepDetection if (showConversationAwareness) {
if (features.hasConversationAwareness && convAwareness != null) { SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
SettingsSwitchItem( SettingsSwitchItem(
icon = Icons.TwoTone.Hearing, icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label), title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description), subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled, checked = convAwareness?.enabled == true,
onCheckedChange = onConversationalAwarenessChange, onCheckedChange = onConversationalAwarenessChange,
enabled = enabled, 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 (features.hasSleepDetection) {
if (showConversationAwareness) ReactionsDivider()
val sleepDet = device.sleepDetection val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true) ?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem( SettingsSwitchItem(
@@ -198,23 +246,24 @@ internal fun ReactionsCard(
requiresUpgrade = !isPro, requiresUpgrade = !isPro,
) )
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) { if (anyPopupEnabled) {
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) {
SettingsInfoBox( SettingsInfoBox(
text = stringResource(R.string.settings_popup_info_not_in_app), 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) { if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog( AutoConnectConditionDialog(
current = reactions.autoConnectCondition, current = reactions.autoConnectCondition,
@@ -246,7 +295,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
device = device, device = device,
features = device.model.features, features = device.model.features,
isPro = state.isPro, isPro = state.isPro,
monitorMode = state.monitorMode,
) )
} }
@@ -259,6 +307,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
device = device, device = device,
features = device.model.features, features = device.model.features,
isPro = state.isPro, isPro = state.isPro,
monitorMode = state.monitorMode,
) )
} }
@@ -81,7 +81,7 @@ internal fun SoundCard(
) )
} }
} }
if (features.hasMicrophoneMode) { if (features.hasMicrophoneMode && device.microphoneMode != null) {
if (isPro) { if (isPro) {
val micMode = device.microphoneMode val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO) ?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
@@ -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 = {},
)
}
@@ -56,17 +56,37 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitorKilledHintCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard 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.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.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.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice 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.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant import java.time.Instant
internal fun profiledDeviceKey(
device: PodDevice,
index: Int,
duplicateProfileIds: Set<String>,
): String {
val pid = requireNotNull(device.profileId)
return if (pid !in duplicateProfileIds) {
"profiled:$pid"
} else {
"profiled:$pid:${device.identifier ?: "idx:$index"}"
}
}
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
"unmatched:${device.identifier ?: "idx:$index"}"
@Composable @Composable
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
ErrorEventHandler(vm) ErrorEventHandler(vm)
@@ -155,10 +175,17 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
}, },
onManageDevices = { vm.goToDeviceManager() }, onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() }, onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onOsKillShowInstructions = { vm.onOsKillShowInstructions() },
onOsKillAutostartSettings = { vm.onOsKillAutostartSettings() },
onOsKillDismiss = { vm.dismissOsKillHint() },
onUpgrade = { vm.onUpgrade() }, onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() }, onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) }, onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) }, onDeviceSettings = { device ->
if (currentState.showReactionsHint) vm.dismissReactionsHint()
vm.goToDeviceSettings(device)
},
onEditProfile = { device -> vm.goToEditProfile(device) }, onEditProfile = { device -> vm.goToEditProfile(device) },
onToggleDeviceExpansion = { device -> onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) } device.profileId?.let { vm.toggleDeviceExpansion(it) }
@@ -174,6 +201,10 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit, onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit, onManageDevices: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onOsKillShowInstructions: () -> Unit = {},
onOsKillAutostartSettings: () -> Unit = {},
onOsKillDismiss: () -> Unit = {},
onUpgrade: () -> Unit, onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit, onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> }, onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -270,6 +301,18 @@ fun OverviewScreen(
) )
} }
// 1b. OS killed the monitor — historical fact, shown regardless of current BT/scan state
if (state.showOsKillHint) {
item(key = "os_kill_hint") {
MonitorKilledHintCard(
showAutostartAction = state.showOsKillAutostartAction,
onShowInstructions = onOsKillShowInstructions,
onAutostartSettings = onOsKillAutostartSettings,
onDismiss = onOsKillDismiss,
)
}
}
// 2. Bluetooth disabled card // 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && !state.isScanBlocked) { if (!state.isBluetoothEnabled && !state.isScanBlocked) {
item(key = "bluetooth_disabled") { item(key = "bluetooth_disabled") {
@@ -286,18 +329,31 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users) // 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) { if (!state.isScanBlocked && state.isBluetoothEnabled) {
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
item(key = "reactions_hint") {
ReactionsMovedHintCard()
}
}
val duplicateProfileIds = state.visibleProfiledDevices
.mapNotNull { it.profileId }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
.keys
itemsIndexed( itemsIndexed(
items = state.visibleProfiledDevices, items = state.visibleProfiledDevices,
key = { _, device -> requireNotNull(device.profileId) }, key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
) { index, device -> ) { index, device ->
val isCollapsed = !state.isExpanded(device, index) val isCollapsed = !state.isExpanded(device, index)
val isToggleable = state.isToggleable(device, index) val isToggleable = state.isToggleable(device, index)
PodDeviceCard( PodDeviceCard(
device = device, device = device,
isPro = state.upgradeInfo.isPro, isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode, showDebug = state.isDebug,
now = state.now, now = state.now,
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
batteryEstimate = state.estimateFor(device),
onToggleCollapse = if (isToggleable) { onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) } { onToggleDeviceExpansion(device) }
} else null, } else null,
@@ -319,32 +375,39 @@ 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 // 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) { if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") { item(key = "monitoring_active") {
MonitoringActiveCard() MonitoringActiveCard()
} }
} }
// 6. Unmatched devices section // 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) { if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") { item(key = "unmatched_header") {
UnmatchedDevicesCard( UnmatchedDevicesCard(
count = state.unmatchedDevices.size, count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices, isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched, onToggle = onToggleUnmatched,
) )
} }
if (state.showUnmatchedDevices) { if (state.showUnmatchedDevices) {
items( itemsIndexed(
items = state.unmatchedDevices, items = state.visibleUnmatchedDevices,
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" }, key = { index, device -> unmatchedDeviceKey(device, index) },
) { device -> ) { _, device ->
PodDeviceCard( PodDeviceCard(
device = device, device = device,
isPro = state.upgradeInfo.isPro, isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode, showDebug = state.isDebug,
now = state.now, now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) }, onAncModeChange = { mode -> onAncModeChange(device, mode) },
onUpgrade = onUpgrade, onUpgrade = onUpgrade,
@@ -365,6 +428,7 @@ private fun PodDeviceCard(
showDebug: Boolean, showDebug: Boolean,
now: Instant, now: Instant,
isCollapsed: Boolean = false, isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null, onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit, onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit, onUpgrade: () -> Unit,
@@ -375,6 +439,7 @@ private fun PodDeviceCard(
device.hasDualPods -> DualPodsCard( device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now, device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse, onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange, onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade, onUpgrade = onUpgrade,
@@ -384,6 +449,7 @@ private fun PodDeviceCard(
device.model != PodModel.UNKNOWN -> SinglePodsCard( device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now, device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse, onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange, onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade, onUpgrade = onUpgrade,
@@ -402,11 +468,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
permissions = emptySet(), permissions = emptySet(),
devices = listOf( devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(), MockPodDataProvider.dualPodFullyLoaded(),
MockPodDataProvider.singlePodMonitored(), MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(), MockPodDataProvider.unknownMonitored(),
), ),
isDebugMode = false, isDebug = false,
isBluetoothEnabled = true, isBluetoothEnabled = true,
profiles = listOf( profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2), MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -414,6 +480,17 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
), ),
upgradeInfo = MockPodDataProvider.fossInfo(), upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false, 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 = {}, onRequestPermission = {},
onBluetoothSettings = {}, onBluetoothSettings = {},
@@ -432,7 +509,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
permissions = emptySet(), permissions = emptySet(),
devices = emptyList(), devices = emptyList(),
isDebugMode = false, isDebug = false,
isBluetoothEnabled = true, isBluetoothEnabled = true,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)), profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(), upgradeInfo = MockPodDataProvider.fossInfo(),
@@ -455,7 +532,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
permissions = emptySet(), permissions = emptySet(),
devices = emptyList(), devices = emptyList(),
isDebugMode = false, isDebug = false,
isBluetoothEnabled = true, isBluetoothEnabled = true,
profiles = emptyList(), profiles = emptyList(),
upgradeInfo = MockPodDataProvider.gplayInfo(), upgradeInfo = MockPodDataProvider.gplayInfo(),
@@ -478,7 +555,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
permissions = emptySet(), permissions = emptySet(),
devices = emptyList(), devices = emptyList(),
isDebugMode = false, isDebug = false,
isBluetoothEnabled = false, isBluetoothEnabled = false,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)), profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true), upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
@@ -4,8 +4,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking import eu.darken.capod.common.datastore.value
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.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN 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.log
@@ -21,7 +21,13 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorKillGuidance
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice 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.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -32,12 +38,16 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow 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.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -48,12 +58,14 @@ class OverviewViewModel @Inject constructor(
private val deviceMonitor: DeviceMonitor, private val deviceMonitor: DeviceMonitor,
private val permissionTool: PermissionTool, private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo, private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo, private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager, private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource, private val timeSource: TimeSource,
private val killGuidance: MonitorKillGuidance,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>() val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -75,29 +87,104 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false) private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet()) private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
val workerAutolaunch = permissionTool.missingScanPermissions private data class OverviewUiSettings(
.onEach { permissions -> val reactionsHintDismissed: Boolean,
if (permissions.isNotEmpty()) { val hideUnmatchedDevices: Boolean,
log(TAG) { "Missing scan permissions: $permissions" } val showTroubleshootSuggestion: Boolean,
val showOsKillHint: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
/**
* True when the OS killed the monitor (see MonitorKillDetector) and the user hasn't dismissed
* the hint since — dismissal only hides kills up to that point, a newer kill re-shows it.
*/
private val osKillHint = combineFlows(
generalSettings.lastOsKillAt.flow,
generalSettings.osKillHintDismissedAt.flow,
) { lastKillAt, dismissedAt ->
lastKillAt > 0 && lastKillAt > dismissedAt
}.distinctUntilChanged()
/**
* 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,
osKillHint,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, showOsKillHint, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
showOsKillHint = showOsKillHint,
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 return@onEach
} }
val shouldStart = when (mode) {
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
MonitorMode.MANUAL -> false MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> { MonitorMode.AUTOMATIC -> anyConnected
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.ALWAYS -> true MonitorMode.ALWAYS -> true
} }
if (shouldStart) { if (shouldStart) {
log(TAG) { "Starting monitor" } log(TAG) { "Starting monitor (mode=$mode)" }
monitorControl.startMonitor() monitorControl.startMonitor()
} }
} }
@@ -124,13 +211,15 @@ class OverviewViewModel @Inject constructor(
updateTicker, updateTicker,
permissionTool.missingPermissions, permissionTool.missingPermissions,
pods, pods,
debugSettings.isDebugModeEnabled.flow, Bugs.isDebug,
bluetoothManager.isBluetoothEnabled, bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles, profilesRepo.profiles,
upgradeRepo.upgradeInfo, upgradeRepo.upgradeInfo,
showUnmatchedDevices, showUnmatchedDevices,
userExpansionOverrides, userExpansionOverrides,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds -> overviewUiSettings,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist) // Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet() val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet() val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -139,12 +228,18 @@ class OverviewViewModel @Inject constructor(
now = timeSource.now(), now = timeSource.now(),
permissions = permissions, permissions = permissions,
devices = devices, devices = devices,
isDebugMode = isDebugMode, isDebug = isDebug,
isBluetoothEnabled = isBluetoothEnabled, isBluetoothEnabled = isBluetoothEnabled,
profiles = profiles, profiles = profiles,
upgradeInfo = upgradeInfo, upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched, showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds, userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
showOsKillHint = uiSettings.showOsKillHint,
showOsKillAutostartAction = killGuidance.hasAutostartSettings,
batteryEstimates = uiSettings.batteryEstimates,
) )
}.asLiveState() }.asLiveState()
@@ -154,15 +249,27 @@ class OverviewViewModel @Inject constructor(
val now: Instant, val now: Instant,
val permissions: Set<Permission>, val permissions: Set<Permission>,
val devices: List<PodDevice>, val devices: List<PodDevice>,
val isDebugMode: Boolean, val isDebug: Boolean,
val isBluetoothEnabled: Boolean, val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>, val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info, val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean, val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(), val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val showOsKillHint: Boolean = false,
val showOsKillAutostartAction: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } 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. */ /** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy { private val profileOrder: Map<String, Int> by lazy {
profiles.mapIndexed { index, profile -> profile.id to index }.toMap() profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
@@ -170,7 +277,7 @@ class OverviewViewModel @Inject constructor(
val profiledDevices: List<PodDevice> by lazy { val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith( devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) } compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE } .thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
) )
} }
@@ -180,6 +287,11 @@ class OverviewViewModel @Inject constructor(
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null } 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 val bluetoothIconState: BluetoothIconState
get() = when { get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN isScanBlocked -> BluetoothIconState.HIDDEN
@@ -218,6 +330,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index) navTo(Nav.Settings.Index)
} }
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() { fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" } log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager) navTo(Nav.Main.DeviceManager)
@@ -252,6 +369,32 @@ class OverviewViewModel @Inject constructor(
} }
} }
fun dismissReactionsHint() {
log(TAG, INFO) { "dismissReactionsHint()" }
launch {
generalSettings.reactionsHintDismissed.value(true)
}
}
fun onOsKillShowInstructions() {
log(TAG, INFO) { "onOsKillShowInstructions()" }
killGuidance.openKillInstructions()
}
fun onOsKillAutostartSettings() {
log(TAG, INFO) { "onOsKillAutostartSettings()" }
killGuidance.openAutostartSettings()
}
fun dismissOsKillHint() {
log(TAG, INFO) { "dismissOsKillHint()" }
launch {
// Anchor the dismissal to the kill being dismissed, not wall-clock "now" — clock
// jumps must neither keep the card visible nor suppress future kills.
generalSettings.osKillHintDismissedAt.value(generalSettings.lastOsKillAt.value())
}
}
fun requestPermission(permission: Permission) { fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" } log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission) requestPermissionEvent.tryEmit(permission)
@@ -271,13 +414,14 @@ class OverviewViewModel @Inject constructor(
companion object { companion object {
private const val FREE_DEVICE_LIMIT = 1 private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
/** Connection tier rank for sorting: lower = higher priority. */ /**
internal fun deviceTierRank(device: PodDevice): Int = when { * How long the "connected but no live data" condition must hold before the troubleshooter
device.isSystemConnected -> 0 * hint appears, so it doesn't flash during the gap between an audio connection and the first
device.isLive -> 1 * BLE broadcast.
else -> 2 */
} private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
} }
} }
@@ -29,8 +29,8 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -60,15 +60,15 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice 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.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState 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.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods 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.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle 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 eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Instant import java.time.Instant
@Composable @Composable
@@ -78,6 +78,7 @@ fun DualPodsCard(
showDebug: Boolean, showDebug: Boolean,
now: Instant, now: Instant,
isCollapsed: Boolean = false, isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null, onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null, onUpgrade: (() -> Unit)? = null,
@@ -168,7 +169,7 @@ fun DualPodsCard(
} }
if (device.profileId != null && onDeviceSettings != null) { if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) { OutlinedIconButton(onClick = onDeviceSettings) {
Icon( Icon(
imageVector = Icons.TwoTone.Tune, imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd), contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -194,6 +195,7 @@ fun DualPodsCard(
device = device, device = device,
showDebug = showDebug, showDebug = showDebug,
now = now, now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange, onAncModeChange = onAncModeChange,
) )
} }
@@ -206,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice, device: PodDevice,
showDebug: Boolean, showDebug: Boolean,
now: Instant, now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) { ) {
val context = LocalContext.current
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side // Circular battery gauges side by side
@@ -227,7 +231,7 @@ private fun ColumnScope.DualPodsCardExpanded(
) { ) {
PodGauge( PodGauge(
iconRes = device.leftPodIcon, iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(), batteryPercent = device.batteryLeft,
chargingState = device.leftPodChargingState chargingState = device.leftPodChargingState
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, ?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isLeftInEar ?: false, isInEar = device.isLeftInEar ?: false,
@@ -235,11 +239,13 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false, isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone, showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
) )
PodGauge( PodGauge(
iconRes = device.rightPodIcon, iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(), batteryPercent = device.batteryRight,
chargingState = device.rightPodChargingState chargingState = device.rightPodChargingState
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, ?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isRightInEar ?: false, isInEar = device.isRightInEar ?: false,
@@ -247,6 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false, isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone, showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
) )
} }
@@ -301,6 +309,8 @@ private fun PodGauge(
isMicrophone: Boolean, isMicrophone: Boolean,
showMicrophone: Boolean, showMicrophone: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
timeRemaining: String? = null,
untilCharged: String? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -358,16 +368,27 @@ private fun PodGauge(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// Battery percentage // Battery percentage, with the time-remaining estimate inline next to it
Text( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()), Text(
style = MaterialTheme.typography.titleMedium, text = formatBatteryPercent(context, batteryPercent),
color = if (batteryPercent >= 0f) { style = MaterialTheme.typography.titleMedium,
MaterialTheme.colorScheme.onSurface color = if (batteryPercent >= 0f) {
} else { MaterialTheme.colorScheme.onSurface
MaterialTheme.colorScheme.onSurfaceVariant } 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)) Spacer(modifier = Modifier.height(4.dp))
@@ -382,6 +403,7 @@ private fun PodGauge(
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label), chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label), inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label), microphoneLabel = stringResource(R.string.pods_microphone_label),
chargingDetail = untilCharged,
) )
} }
} }
@@ -412,7 +434,7 @@ private fun CaseRow(
) )
BatteryCapsule( BatteryCapsule(
percent = device.batteryCase.toBatteryFloat(), percent = device.batteryCase,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(8.dp), .height(8.dp),
@@ -456,10 +478,49 @@ private fun CaseRow(
@Composable @Composable
private fun DualPodsCardFullPreview() = PreviewWrapper { private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard( DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(), device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false, showDebug = false,
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
isPro = false, 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 = {}, onDeviceSettings = {},
) )
} }
@@ -0,0 +1,97 @@
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.BatterySaver
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun MonitorKilledHintCard(
showAutostartAction: Boolean,
onShowInstructions: () -> Unit,
onAutostartSettings: () -> Unit,
onDismiss: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_os_kill_hint_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_os_kill_hint_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
// Stacked instead of a single Row — up to three actions don't fit on narrow screens,
// especially localized or at increased font scale.
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.End,
) {
Button(onClick = onShowInstructions) {
Text(text = stringResource(R.string.overview_os_kill_hint_instructions_action))
}
if (showAutostartAction) {
OutlinedButton(onClick = onAutostartSettings) {
Text(text = stringResource(R.string.overview_os_kill_hint_autostart_action))
}
}
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.overview_os_kill_hint_dismiss_action))
}
}
}
}
}
@Preview2
@Composable
private fun MonitorKilledHintCardPreview() = PreviewWrapper {
MonitorKilledHintCard(
showAutostartAction = true,
onShowInstructions = {},
onAutostartSettings = {},
onDismiss = {},
)
}
@@ -0,0 +1,89 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun ReactionsMovedHintCard(
modifier: Modifier = Modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = stringResource(R.string.overview_reactions_hint_title),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.overview_reactions_hint_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 18.sp,
)
}
}
}
}
@Preview2
@Composable
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
ReactionsMovedHintCard()
}
@@ -28,8 +28,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -57,10 +57,14 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice 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.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState 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.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.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant import java.time.Instant
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@@ -71,6 +75,7 @@ fun SinglePodsCard(
showDebug: Boolean, showDebug: Boolean,
now: Instant, now: Instant,
isCollapsed: Boolean = false, isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null, onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null, onUpgrade: (() -> Unit)? = null,
@@ -150,7 +155,7 @@ fun SinglePodsCard(
} }
if (device.profileId != null && onDeviceSettings != null) { if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) { OutlinedIconButton(onClick = onDeviceSettings) {
Icon( Icon(
imageVector = Icons.TwoTone.Tune, imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd), contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -176,6 +181,7 @@ fun SinglePodsCard(
device = device, device = device,
showDebug = showDebug, showDebug = showDebug,
now = now, now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange, onAncModeChange = onAncModeChange,
) )
} }
@@ -188,21 +194,23 @@ private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice, device: PodDevice,
showDebug: Boolean, showDebug: Boolean,
now: Instant, now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?, onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clamped = device.batteryHeadset?.coerceIn(0f, 1f) val percent = device.batteryHeadset
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f, targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing), animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress", label = "gaugeProgress",
) )
val ringColor = when { val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant !isKnown -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary percent > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -237,7 +245,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
) )
// Progress ring // Progress ring
if (clamped != null) { if (isKnown) {
CircularProgressIndicator( CircularProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.size(88.dp), modifier = Modifier.size(88.dp),
@@ -248,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
) )
} }
// Battery text inside ring // Battery percentage + time remaining, stacked inside the ring
Text( Column(horizontalAlignment = Alignment.CenterHorizontally) {
text = formatBatteryPercent(context, device.batteryHeadset), Text(
style = MaterialTheme.typography.headlineSmall, text = formatBatteryPercent(context, percent),
color = if (device.batteryHeadset != null) { style = MaterialTheme.typography.headlineSmall,
MaterialTheme.colorScheme.onSurface color = if (isKnown) {
} else { MaterialTheme.colorScheme.onSurface
MaterialTheme.colorScheme.onSurfaceVariant } 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)) Spacer(modifier = Modifier.height(8.dp))
@@ -274,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label), label = stringResource(R.string.pods_charging_optimized_label),
) )
AapPodState.ChargingState.CHARGING -> StatusChip( AapPodState.ChargingState.CHARGING -> {
icon = Icons.TwoTone.BatteryChargingFull, // Time-until-charged lives in the charging chip; the in-ring estimate
label = stringResource(R.string.pods_charging_label), // 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 else -> Unit
} }
if (device.isBeingWorn == true) { if (device.isBeingWorn == true) {
@@ -330,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 @Preview2
@Composable @Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper { 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 = {})
}
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@Composable @Composable
fun CompactBatterySummary( fun CompactBatterySummary(
device: PodDevice, device: PodDevice,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hasAnyBattery = device.batteryLeft != null val hasAnyBattery = isKnownBattery(device.batteryLeft)
|| device.batteryRight != null || isKnownBattery(device.batteryRight)
|| device.batteryHeadset != null || isKnownBattery(device.batteryHeadset)
|| device.batteryCase != null || isKnownBattery(device.batteryCase)
Surface( Surface(
modifier = modifier modifier = modifier
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
percent = device.batteryRight, percent = device.batteryRight,
) )
if (device.hasCase && device.batteryCase != null) { if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device) MiniCaseCluster(device = device)
} }
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
iconRes = null, iconRes = null,
percent = device.batteryHeadset, percent = device.batteryHeadset,
) )
if (device.hasCase && device.batteryCase != null) { if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device) MiniCaseCluster(device = device)
} }
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
@Composable @Composable
private fun MiniPodRing( private fun MiniPodRing(
iconRes: Int?, iconRes: Int?,
percent: Float?, percent: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f) val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f, targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing), animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress", label = "miniGaugeProgress",
) )
val ringColor = when { val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant !isKnown -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary percent > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -155,7 +157,7 @@ private fun MiniPodRing(
trackColor = MaterialTheme.colorScheme.surfaceVariant, trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round, strokeCap = StrokeCap.Round,
) )
if (clamped != null) { if (isKnown) {
CircularProgressIndicator( CircularProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
@@ -177,7 +179,7 @@ private fun MiniPodRing(
Text( Text(
text = formatBatteryPercent(context, percent), text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = if (percent != null) { color = if (isKnown) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule( BatteryCapsule(
percent = device.batteryCase ?: -1f, percent = device.batteryCase,
modifier = Modifier modifier = Modifier
.width(36.dp) .width(36.dp)
.height(6.dp), .height(6.dp),
@@ -71,6 +71,7 @@ fun StatusChipRow(
inEarLabel: String, inEarLabel: String,
microphoneLabel: String, microphoneLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
chargingDetail: String? = null,
) { ) {
FlowRow( FlowRow(
modifier = modifier.animateContentSize(), modifier = modifier.animateContentSize(),
@@ -82,9 +83,11 @@ fun StatusChipRow(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = chargingOptimizedLabel, 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( AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel, label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
) )
else -> Unit else -> Unit
} }
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
chargingOptimizedLabel = "Optimized", chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear", inEarLabel = "In Ear",
microphoneLabel = "Mic", microphoneLabel = "Mic",
chargingDetail = "25m",
) )
} }
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return val currentState = state ?: return
val isAapConnected = currentState.device?.isAapConnected == true
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
LaunchedEffect(profileId, isAapConnected) {
if (isAapConnected) {
hasSeenAapConnected = true
} else if (hasSeenAapConnected && !didAutoNavigate) {
didAutoNavigate = true
vm.navUp()
}
}
PressControlsScreen( PressControlsScreen(
state = currentState, state = currentState,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
@@ -1,51 +1,37 @@
package eu.darken.capod.main.ui.settings.general package eu.darken.capod.main.ui.settings.general
import android.os.Build 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.AccountTree 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.Contrast
import androidx.compose.material.icons.twotone.DarkMode 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.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.SettingsBluetooth import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.bluetooth.ScannerMode
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler
@@ -57,7 +43,6 @@ import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeState import eu.darken.capod.common.theming.ThemeState
import eu.darken.capod.common.theming.ThemeStyle import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
@Composable @Composable
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) { fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
@@ -69,14 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
GeneralSettingsScreen( GeneralSettingsScreen(
state = it, state = it,
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) }, onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) }, onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) }, onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) }, onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) }, onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) }, onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) }, onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) }, onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -89,22 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
fun GeneralSettingsScreen( fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State, state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onScannerModeSelected: (ScannerMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit, onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit, onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit, onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit, onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit, onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {}, onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {}, onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {}, onThemeColorSelected: (ThemeColor) -> Unit = {},
onUpgrade: () -> Unit = {}, onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showScannerModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) } var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU && val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -127,25 +106,6 @@ fun GeneralSettingsScreen(
}, },
) { innerPadding -> ) { innerPadding ->
LazyColumn(modifier = Modifier.padding(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 {
SettingsBaseItem(
title = stringResource(R.string.settings_scanner_mode_label),
subtitle = stringResource(state.scannerMode.labelRes),
icon = Icons.TwoTone.SettingsBluetooth,
onClick = { showScannerModeDialog = true },
)
}
item { item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label)) SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
} }
@@ -242,10 +202,17 @@ fun GeneralSettingsScreen(
} }
item { item {
SettingsBaseItem( SettingsBaseItem(
title = stringResource(R.string.settings_debug_label), title = stringResource(R.string.settings_overview_hide_unmatched_label),
subtitle = stringResource(R.string.settings_debug_description), subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
icon = Icons.TwoTone.BugReport, icon = Icons.TwoTone.VisibilityOff,
onClick = onDebugSettings, onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
trailingContent = {
Switch(
checked = state.hideUnmatchedDevices,
onCheckedChange = onHideUnmatchedDevicesChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
) )
} }
item { item {
@@ -299,34 +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 (showScannerModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_scanner_mode_label),
entries = ScannerMode.entries,
selectedEntry = state.scannerMode,
onEntrySelected = {
onScannerModeSelected(it)
showScannerModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showScannerModeDialog = false },
)
}
if (showColorDialog) { if (showColorDialog) {
ThemeColorSelectorDialog( ThemeColorSelectorDialog(
selectedColor = state.themeState.color, selectedColor = state.themeState.color,
@@ -341,13 +280,12 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State( private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro, isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC,
scannerMode = ScannerMode.BALANCED,
showConnectedNotification = true, showConnectedNotification = true,
keepNotificationAfterDisconnect = false, keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false, isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false, isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false, useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(), themeState = ThemeState(),
) )
@@ -357,14 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
GeneralSettingsScreen( GeneralSettingsScreen(
state = previewGeneralState(isPro = true), state = previewGeneralState(isPro = true),
onNavigateUp = {}, onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {}, onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {}, onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
) )
} }
@@ -374,61 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
GeneralSettingsScreen( GeneralSettingsScreen(
state = previewGeneralState(isPro = false), state = previewGeneralState(isPro = false),
onNavigateUp = {}, onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {}, onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {}, onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
) onHideUnmatchedDevicesChanged = {},
}
@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))
}
},
) )
} }
@@ -1,7 +1,6 @@
package eu.darken.capod.main.ui.settings.general package eu.darken.capod.main.ui.settings.general
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO 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.log
@@ -14,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.themeState import eu.darken.capod.main.core.themeState
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -31,13 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
data class State( data class State(
val isPro: Boolean, val isPro: Boolean,
val monitorMode: MonitorMode,
val scannerMode: ScannerMode,
val showConnectedNotification: Boolean, val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean, val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean, val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean, val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean, val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState, val themeState: ThemeState,
) )
@@ -45,13 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine( val state = combine(
combine( combine(
generalSettings.monitorMode.flow,
generalSettings.scannerMode.flow,
generalSettings.useExtraMonitorNotification.flow, generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow, generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, scannerMode, showNotif, keepNotif -> ) { showNotif, keepNotif ->
@Suppress("USELESS_CAST") @Suppress("USELESS_CAST")
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any) arrayOf<Any>(showNotif as Any, keepNotif as Any)
}, },
combine( combine(
generalSettings.isOffloadedFilteringDisabled.flow, generalSettings.isOffloadedFilteringDisabled.flow,
@@ -63,30 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
}, },
generalSettings.themeState, generalSettings.themeState,
isPro, isPro,
) { general, compat, themeState, isPro -> generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State( State(
isPro = isPro, isPro = isPro,
monitorMode = general[0] as MonitorMode, showConnectedNotification = general[0] as Boolean,
scannerMode = general[1] as ScannerMode, keepNotificationAfterDisconnect = general[1] as Boolean,
showConnectedNotification = general[2] as Boolean,
keepNotificationAfterDisconnect = general[3] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean, isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean, isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean, useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState, themeState = themeState,
) )
}.asLiveState() }.asLiveState()
fun setMonitorMode(mode: MonitorMode) {
log(TAG, INFO) { "setMonitorMode($mode)" }
generalSettings.monitorMode.valueBlocking = mode
}
fun setScannerMode(mode: ScannerMode) {
log(TAG, INFO) { "setScannerMode($mode)" }
generalSettings.scannerMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) { fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" } log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -112,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
} }
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch { fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" } log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) { if (isPro.first()) {
@@ -144,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade) navTo(Nav.Main.Upgrade)
} }
fun goToDebugSettings() {
log(TAG, INFO) { "goToDebugSettings()" }
navTo(Nav.Settings.Debug)
}
companion object { companion object {
private val TAG = logTag("Settings", "General", "VM") 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")
}
}
@@ -0,0 +1,142 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
*
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
* If the pending-job state lived on the service, taps across consecutive panel
* sessions wouldn't cancel each other — every tap would fire a separate
* `SetAncMode`, overwhelming the AAP verification loop and triggering
* "Rejected after retry" storms that leave the device unresponsive until the
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
* session B can cancel the deferred send queued by panel session A.
*/
@Singleton
class AncTileSendCoordinator @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val aapManager: AapConnectionManager,
) {
private val lock = Any()
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
fun scheduleSetAncMode(
address: BluetoothAddress,
mode: AapSetting.AncMode.Value,
debounce: Duration,
timeout: Duration = 5.seconds,
) {
synchronized(lock) {
val replacing = pendingJobs[address]?.isActive == true
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
_pendingModes.value = _pendingModes.value + (address to mode)
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
pendingJobs[address] = appScope.launch {
delay(debounce)
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
try {
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
} catch (e: CancellationException) {
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
clearPendingTarget(address, mode)
}
}
timeoutJobs[address] = appScope.launch {
delay(timeout)
if (clearPendingTargetFromTimeout(address, mode)) {
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
}
}
}
}
internal fun applyPendingTarget(state: AncTileState): AncTileState {
val active = state as? AncTileState.Active ?: return state
val address = active.deviceAddress ?: return active
val target = pendingModes.value[address] ?: return active
if (target !in active.visible) return active
if (active.isConfirmed(target)) return active
return active.copy(pending = target)
}
internal fun acknowledgeDeviceState(state: AncTileState) {
val active = state as? AncTileState.Active ?: return
val address = active.deviceAddress ?: return
val target = pendingModes.value[address] ?: return
if (target !in active.visible || active.isConfirmed(target)) {
clearPendingTarget(address, target)
}
}
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
pending == target || (current == target && pending == null)
private fun clearPendingTarget(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
true
}
private fun clearPendingTargetFromTimeout(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)
true
}
companion object {
private val TAG = logTag("Tile", "Anc", "Coord")
}
}
@@ -0,0 +1,227 @@
package eu.darken.capod.main.ui.tile
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.SystemClock
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.DispatcherProvider
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
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
* QS panel instead of a home-screen widget.
*/
@AndroidEntryPoint
class AncTileService : TileService() {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
@Inject lateinit var stateStore: AncTileStateStore
private var listenScope: CoroutineScope? = null
private val instanceId = Integer.toHexString(System.identityHashCode(this))
override fun onCreate() {
super.onCreate()
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
}
override fun onTileAdded() {
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
// Render a static placeholder so we never block SystemUI's bind path on a
// first-emission DataStore/Billing read. onStartListening will fill in real state.
renderTile(AncTileState.Connecting)
}
override fun onStartListening() {
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
listenScope?.cancel()
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
listenScope = scope
scope.launch {
stateStore.state.throttleLatest(250).collect { state ->
log(TAG, VERBOSE) { "collector: state=$state" }
withContext(dispatcherProvider.Main) { renderTile(state) }
}
}
}
override fun onStopListening() {
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
listenScope?.cancel()
listenScope = null
}
override fun onDestroy() {
val now = SystemClock.elapsedRealtime()
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
lastDestroyAt = now
listenScope?.cancel()
super.onDestroy()
}
override fun onClick() {
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
val state = stateStore.currentState()
log(TAG, VERBOSE) {
"onClick: resolved from state store=$state"
}
dispatchClick(state)
}
private fun dispatchClick(state: AncTileState) {
log(TAG, VERBOSE) { "dispatchClick($state)" }
when (state) {
AncTileState.NotPro,
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
is AncTileState.Active -> sendNextMode(state)
AncTileState.BluetoothOff,
AncTileState.NoDevice,
AncTileState.NoAncSupport,
AncTileState.NotConnected,
AncTileState.Connecting -> {
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
// but defensively no-op so we don't crash on unexpected delivery.
}
}
}
private fun sendNextMode(state: AncTileState.Active) {
val nextMode = pickNextMode(state.visible, state.current, state.pending)
log(TAG, VERBOSE) {
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
}
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
return
}
val address: BluetoothAddress = state.deviceAddress ?: run {
log(TAG, WARN) { "sendNextMode: state has no device address" }
return
}
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
val optimisticState = state.copy(pending = nextMode)
renderTile(optimisticState)
}
private fun openMainActivityWithUpgrade() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
}
if (hasApiLevel(34)) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
}
private fun renderTile(state: AncTileState) {
val tile = qsTile ?: run {
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
return
}
log(TAG, VERBOSE) { "renderTile: $state" }
val baseLabel = getString(R.string.tile_anc_label)
val (subtitle, iconRes, tileState) = when (state) {
AncTileState.NotPro -> Triple(
getString(R.string.common_upgrade_required_label),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.PermissionRequired -> Triple(
getString(R.string.tile_anc_subtitle_permission_required),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.BluetoothOff -> Triple(
getString(R.string.tile_anc_subtitle_bluetooth_off),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoDevice -> Triple(
getString(R.string.tile_anc_subtitle_no_device),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoAncSupport -> Triple(
getString(R.string.tile_anc_subtitle_no_anc_support),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NotConnected -> Triple(
getString(R.string.anc_widget_aap_not_connected_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.Connecting -> Triple(
getString(R.string.anc_widget_aap_connecting_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
is AncTileState.Active -> {
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
Triple(
displayMode.shortLabel(this),
displayMode.iconDrawableRes(),
Tile.STATE_ACTIVE,
)
}
}
tile.icon = Icon.createWithResource(this, iconRes)
tile.state = tileState
if (hasApiLevel(29)) {
tile.label = baseLabel
tile.subtitle = subtitle
} else {
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
tile.label = "$baseLabel · $subtitle"
}
if (hasApiLevel(30)) {
tile.stateDescription = subtitle
}
tile.updateTile()
}
companion object {
private val TAG = logTag("Tile", "Anc")
@Volatile private var lastDestroyAt: Long? = null
private fun elapsedSinceLastDestroy(): String {
val destroyedAt = lastDestroyAt ?: return "n/a"
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
}
}
}
@@ -0,0 +1,88 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
* stay in sync, while remaining unit-testable without Android resources.
*
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
* be misleading because nothing is in progress.
*/
object AncTileStateMapper {
fun map(
device: PodDevice?,
isPro: Boolean,
isBluetoothEnabled: Boolean,
missingPermissions: Set<Permission>,
): AncTileState {
if (!isPro) return AncTileState.NotPro
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
if (device == null) return AncTileState.NoDevice
if (!device.hasAncControl) return AncTileState.NoAncSupport
if (!device.isAapConnected) return AncTileState.NotConnected
if (!device.isAapReady) return AncTileState.Connecting
val ancMode = device.ancMode ?: return AncTileState.Connecting
val visible = device.visibleAncModes
if (visible.isEmpty()) return AncTileState.Connecting
return AncTileState.Active(
current = ancMode.current,
pending = device.pendingAncMode,
visible = visible,
deviceLabel = device.label,
deviceAddress = device.address,
)
}
}
/**
* A permission whose absence prevents the tile from working. Scan-blocking permissions
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
* the tile cannot do useful work.
*/
private val Permission.isTileBlocking: Boolean
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
sealed interface AncTileState {
data object NotPro : AncTileState
data object PermissionRequired : AncTileState
data object BluetoothOff : AncTileState
data object NoDevice : AncTileState
data object NoAncSupport : AncTileState
data object NotConnected : AncTileState
data object Connecting : AncTileState
data class Active(
val current: AapSetting.AncMode.Value,
val pending: AapSetting.AncMode.Value?,
val visible: List<AapSetting.AncMode.Value>,
val deviceLabel: String?,
val deviceAddress: BluetoothAddress?,
) : AncTileState
}
/**
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
* [visible] so rapid taps walk forward through the list rather than oscillate against
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
* Allow Off mid-cycle) falls through to [current].
*/
internal fun pickNextMode(
visible: List<AapSetting.AncMode.Value>,
current: AapSetting.AncMode.Value?,
pending: AapSetting.AncMode.Value?,
): AapSetting.AncMode.Value? {
if (visible.isEmpty()) return null
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
val idx = visible.indexOf(anchor)
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
}
@@ -0,0 +1,85 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.primaryByTier
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.combine as combineFlows
/**
* Process-scoped state holder for the ANC QS tile.
*
* TileService instances are short-lived and can disappear between taps. Keeping
* the latest state here lets a recreated service resolve clicks synchronously
* while the app process is alive. A cold or hydrating process intentionally
* reports [AncTileState.Connecting] so the tile UI is honest about not being
* ready for input yet.
*/
@Singleton
class AncTileStateStore @Inject constructor(
@AppScope private val appScope: CoroutineScope,
deviceMonitor: DeviceMonitor,
profilesRepo: DeviceProfilesRepo,
upgradeRepo: UpgradeRepo,
bluetoothManager: BluetoothManager2,
permissionTool: PermissionTool,
private val sendCoordinator: AncTileSendCoordinator,
) {
private val rawState: StateFlow<AncTileState> = combine(
deviceMonitor.devices,
profilesRepo.profiles,
upgradeRepo.upgradeInfo.map { it.isPro },
bluetoothManager.isBluetoothEnabled,
permissionTool.missingPermissions,
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
val device = devices.primaryByTier(profileOrder)
AncTileStateMapper.map(
device = device,
isPro = isPro,
isBluetoothEnabled = isBluetoothEnabled,
missingPermissions = missingPermissions,
)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
init {
appScope.launch {
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
}
}
val state: StateFlow<AncTileState> = combineFlows(
rawState,
sendCoordinator.pendingModes,
) { rawState, _ ->
sendCoordinator.applyPendingTarget(rawState)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
}
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class AncGlanceWidget : GlanceAppWidget() { class AncGlanceWidget : GlanceAppWidget() {
@@ -43,21 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: AncWidgetEntryPoint val ep: AncWidgetEntryPoint
val appWidgetId: Int val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try { try {
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java) ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
ep.widgetSettings().migrateLegacyConfigIfNeeded( ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId, appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId), AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
) )
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent { provideContent {
@@ -77,25 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
} }
provideContent { provideContent {
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width val widthDp = LocalSize.current.width
val heightDp = LocalSize.current.height val heightDp = LocalSize.current.height
val state = try { // Glance keeps the content session alive and does not restart provideGlance()
val config = ep.widgetSettings().getWidgetConfig(appWidgetId) // for every update(). Observe a widget-key-deduped device flow so visible state
val profileId = config.profileId // changes update active sessions without recomposing on every BLE advertisement.
val theme = config.theme val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
val liveDevice = devices.firstOrNull { it.profileId == profileId } .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } .getOrDefault(false)
val initialDevice = remember(config.profileId) {
val profileLabel = profileId?.let { pid -> config.profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
.getOrNull()
}
} }
val device by config.profileId
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
}
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
.getOrNull()
}
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
val widthCells = getCellsForSize(widthDp.value.toInt()) val widthCells = getCellsForSize(widthDp.value.toInt())
val heightCells = getCellsForSize(heightDp.value.toInt()) val heightCells = getCellsForSize(heightDp.value.toInt())
@@ -111,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
AncWidgetRenderStateMapper.map( AncWidgetRenderStateMapper.map(
context = context, context = context,
device = device, device = device,
theme = theme, theme = config.theme,
isPro = isPro, isPro = isPro,
hasConfiguredProfile = profileId != null, hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel, profileLabel = profileLabel,
layout = layout, layout = layout,
) )
} catch (e: Exception) { } else {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
AncWidgetRenderState.Message( AncWidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.updateAll
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
fun aapConnectionManager(): AapConnectionManager fun aapConnectionManager(): AapConnectionManager
fun widgetSettings(): WidgetSettings fun widgetSettings(): WidgetSettings
fun deviceMonitor(): DeviceMonitor fun deviceMonitor(): DeviceMonitor
fun widgetManager(): WidgetManager
} }
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
val device = ep.deviceMonitor().getDeviceForProfile(profileId) val device = ep.deviceMonitor().getDeviceForProfile(profileId)
if (device == null) { if (device == null) {
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" } log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
return return
} }
val address = device.address val address = device.address
if (address == null) { if (address == null) {
log(TAG, ERROR) { "onAction: device has no address" } log(TAG, ERROR) { "onAction: device has no address" }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
return return
} }
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" } log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
} }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
} }
companion object { companion object {
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Context import android.content.Context
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel import eu.darken.capod.main.ui.components.shortLabel
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.monitor.core.visibleAncModes
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
primaryText = context.getString(R.string.anc_widget_no_anc_support_label), primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
) )
!device.isAapConnected -> AncWidgetRenderState.Message( !device.isAapConnected -> {
theme = theme, val secondaryRes = if (device.ble != null) {
resolvedBgColor = bgColor, R.string.anc_widget_aap_not_connected_nearby_description
resolvedTextColor = textColor, } else {
resolvedIconColor = iconColor, R.string.anc_widget_aap_not_connected_not_nearby_description
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label), }
) AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = profileLabel ?: device.getLabel(context),
secondaryText = context.getString(secondaryRes),
)
}
!device.isAapReady -> AncWidgetRenderState.Message( !device.isAapReady -> AncWidgetRenderState.Message(
theme = theme, theme = theme,
@@ -121,10 +130,3 @@ object AncWidgetRenderStateMapper {
} }
} }
} }
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
@@ -23,8 +24,14 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
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.takeFor
import eu.darken.capod.profiles.core.DeviceProfilesRepo 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() { class BatteryGlanceWidget : GlanceAppWidget() {
@@ -38,26 +45,21 @@ class BatteryGlanceWidget : GlanceAppWidget() {
fun upgradeRepo(): UpgradeRepo fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo fun deviceProfilesRepo(): DeviceProfilesRepo
fun batteryEstimator(): BatteryEstimator
} }
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: WidgetEntryPoint val ep: WidgetEntryPoint
val appWidgetId: Int val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try { try {
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
ep.widgetSettings().migrateLegacyConfigIfNeeded( ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId, appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId), AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
) )
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent { provideContent {
@@ -78,38 +80,59 @@ class BatteryGlanceWidget : GlanceAppWidget() {
} }
provideContent { provideContent {
// Composable reads — must be outside try-catch
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width val widthDp = LocalSize.current.width
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt())) val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try { // Glance keeps the content session alive and does not restart provideGlance()
val config = ep.widgetSettings().getWidgetConfig(appWidgetId) // for every update(). Observe a widget-key-deduped device flow so visible state
val profileId = config.profileId // changes update active sessions without recomposing on every BLE advertisement.
val theme = config.theme val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
val liveDevice = devices.firstOrNull { it.profileId == profileId } .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } .getOrDefault(false)
val initialDevice = remember(config.profileId) {
val profileLabel = profileId?.let { pid -> config.profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
.getOrNull()
}
} }
val device by config.profileId
?.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 }
}
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
.getOrNull()
}
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
WidgetRenderStateMapper.map( WidgetRenderStateMapper.map(
context = context, context = context,
device = device, device = device,
theme = theme, theme = config.theme,
isPro = isPro, isPro = isPro,
hasConfiguredProfile = profileId != null, hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel, profileLabel = profileLabel,
layout = layout, layout = layout,
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
) )
} catch (e: Exception) { } else {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
WidgetRenderState.Message( WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -129,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT), textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT), iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
) ).withLocalizedPreviewEstimates(context)
provideContent { provideContent {
GlanceWidgetContent(state = previewState, context = context) GlanceWidgetContent(state = previewState, context = context)
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@@ -96,9 +96,9 @@ private fun DualPodPreview(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, 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.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( DeviceLabel(
label = state.deviceLabel, label = state.deviceLabel,
@@ -111,8 +111,8 @@ private fun DualPodPreview(
BatteryLayout.NARROW -> { BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, 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) PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint) PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
DeviceLabel( DeviceLabel(
label = state.deviceLabel, label = state.deviceLabel,
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatPercent(state.percent.toBatteryOrNull()), text = formatPercent(state.percent) + estimateSuffix(state.estimate),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
) )
if (state.charging) { if (state.charging) {
@@ -297,39 +298,54 @@ private fun PodItemRow(
textColor: Color, textColor: Color,
iconTint: ColorFilter, iconTint: ColorFilter,
iconSize: Int = 20, iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Column(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Image( Row(
painter = painterResource(icon), horizontalArrangement = Arrangement.Center,
contentDescription = null, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.size(iconSize.dp), ) {
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image( Image(
painter = painterResource(R.drawable.ic_baseline_power_24), painter = painterResource(icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp), modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint, 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) { if (estimateBelow && estimate != null) {
Image( Text(
painter = painterResource(R.drawable.ic_baseline_hearing_24), text = estimate,
contentDescription = null, fontSize = 12.sp,
modifier = Modifier.size(20.dp), color = textColor,
colorFilter = iconTint, maxLines = 1,
) )
} }
} }
@@ -354,7 +370,7 @@ private fun TinyPodItem(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatPercent(percent.toBatteryOrNull()), text = formatPercent(percent),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
@@ -382,9 +398,10 @@ private fun DeviceLabel(
} }
} }
private fun formatPercent(percent: Float?): String { private fun formatPercent(percent: Float): String =
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "" if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
}
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@Preview2 @Preview2
@Composable @Composable
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.main.ui.MainActivity import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically, 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.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) GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
BatteryLayout.NARROW -> { BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp) val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, 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) GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint) GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor) GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatGlancePercent(state.percent.toBatteryOrNull()), text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
style = textStyle, style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 8.dp), modifier = GlanceModifier.padding(horizontal = 8.dp),
) )
if (state.charging) { if (state.charging) {
@@ -257,37 +258,49 @@ private fun GlancePodItem(
textStyle: TextStyle, textStyle: TextStyle,
iconTint: ColorFilter, iconTint: ColorFilter,
iconSize: Int = 20, iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: GlanceModifier = GlanceModifier, modifier: GlanceModifier = GlanceModifier,
) { ) {
Row( Column(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image( Row(verticalAlignment = Alignment.CenterVertically) {
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image( Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24), provider = ImageProvider(icon),
contentDescription = null, contentDescription = null,
modifier = GlanceModifier.size(20.dp), modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint, 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) { if (estimateBelow && estimate != null) {
Image( Text(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24), text = estimate,
contentDescription = null, style = textStyle,
modifier = GlanceModifier.size(20.dp), maxLines = 1,
colorFilter = iconTint,
) )
} }
} }
@@ -312,7 +325,7 @@ private fun GlanceTinyPodItem(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatGlancePercent(percent.toBatteryOrNull()), text = formatGlancePercent(percent),
style = textStyle, style = textStyle,
maxLines = 1, maxLines = 1,
modifier = GlanceModifier.padding(start = 4.dp), modifier = GlanceModifier.padding(start = 4.dp),
@@ -341,6 +354,7 @@ private fun GlanceDeviceLabel(
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb)) private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float?): String { private fun formatGlancePercent(percent: Float): String =
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "" 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), bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme), textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme), iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel) ).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
} }
ComposeWidgetPreview(state = batteryState, modifier = innerModifier) ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
} }
@@ -0,0 +1,11 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
.distinctUntilChangedBy { it?.toWidgetKey() }
@@ -1,31 +1,42 @@
package eu.darken.capod.main.ui.widget package eu.darken.capod.main.ui.widget
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/** /**
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to * Snapshot of the [PodDevice] fields that affect widget rendering. Used to
* gate widget refreshes so RSSI / reliability / scan-counter churn doesn't * gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
* cause a refresh on every BLE advertisement. * doesn't cause a refresh on every BLE advertisement.
*/ */
internal data class WidgetDeviceKey( internal data class WidgetDeviceKey(
val profileId: String?, val profileId: String?,
val profileLabel: String?,
val model: PodModel, val model: PodModel,
val batteryLeft: Float?, val batteryLeft: Float,
val batteryRight: Float?, val batteryRight: Float,
val batteryCase: Float?, val batteryCase: Float,
val batteryHeadset: Float?, val batteryHeadset: Float,
val isLeftPodCharging: Boolean?, val isLeftPodCharging: Boolean?,
val isRightPodCharging: Boolean?, val isRightPodCharging: Boolean?,
val isCaseCharging: Boolean?, val isCaseCharging: Boolean?,
val isHeadsetBeingCharged: Boolean?, val isHeadsetBeingCharged: Boolean?,
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val isAapConnected: Boolean,
val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
val ancMode: AapSetting.AncMode.Value?, val ancMode: AapSetting.AncMode.Value?,
val pendingAncMode: AapSetting.AncMode.Value?, val pendingAncMode: AapSetting.AncMode.Value?,
val visibleAncModes: List<AapSetting.AncMode.Value>,
) )
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey( internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
profileId = profileId, profileId = profileId,
profileLabel = label,
model = model, model = model,
batteryLeft = batteryLeft, batteryLeft = batteryLeft,
batteryRight = batteryRight, batteryRight = batteryRight,
@@ -35,6 +46,14 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isRightPodCharging = isRightPodCharging, isRightPodCharging = isRightPodCharging,
isCaseCharging = isCaseCharging, isCaseCharging = isCaseCharging,
isHeadsetBeingCharged = isHeadsetBeingCharged, isHeadsetBeingCharged = isHeadsetBeingCharged,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
ancMode = ancMode?.current, ancMode = ancMode?.current,
pendingAncMode = pendingAncMode, pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes,
) )
@@ -1,8 +1,18 @@
package eu.darken.capod.main.ui.widget package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import androidx.glance.appwidget.updateAll import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
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.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,7 +23,66 @@ class WidgetManager @Inject constructor(
) { ) {
suspend fun refreshWidgets() { suspend fun refreshWidgets() {
BatteryGlanceWidget().updateAll(context) val manager = GlanceAppWidgetManager(context)
AncGlanceWidget().updateAll(context) refreshWidget(
name = "battery",
widget = BatteryGlanceWidget(),
providerClass = BatteryGlanceWidget::class.java,
receiverClass = WidgetProvider::class.java,
manager = manager,
)
refreshWidget(
name = "anc",
widget = AncGlanceWidget(),
providerClass = AncGlanceWidget::class.java,
receiverClass = AncWidgetProvider::class.java,
manager = manager,
)
}
private suspend fun <T : GlanceAppWidget> refreshWidget(
name: String,
widget: T,
providerClass: Class<T>,
receiverClass: Class<*>,
manager: GlanceAppWidgetManager,
) {
val ids = manager.getGlanceIds(providerClass).ifEmpty {
val appWidgetIds = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, receiverClass))
.toList()
if (appWidgetIds.isNotEmpty()) {
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
}
appWidgetIds.mapNotNull { appWidgetId ->
runCatching { manager.getGlanceIdBy(appWidgetId) }
.onFailure { error ->
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
}
.getOrNull()
}
}
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
ids.forEach { id ->
runCatching { widget.update(context, id) }
.onFailure { error ->
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
}
}
}
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
map { it.toAppWidgetId(manager) }
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
manager.getAppWidgetId(this)
}.getOrElse {
AppWidgetManager.INVALID_APPWIDGET_ID
}
companion object {
val TAG = logTag("Widget", "Manager")
} }
} }
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
enum class BatteryLayout { enum class BatteryLayout {
TINY_COLUMN, TINY_COLUMN,
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
val leftPercent: Float, val leftPercent: Float,
val leftCharging: Boolean, val leftCharging: Boolean,
val leftInEar: Boolean, val leftInEar: Boolean,
val leftEstimate: String?,
@DrawableRes val rightIcon: Int, @DrawableRes val rightIcon: Int,
val rightPercent: Float, val rightPercent: Float,
val rightCharging: Boolean, val rightCharging: Boolean,
val rightInEar: Boolean, val rightInEar: Boolean,
val rightEstimate: String?,
@DrawableRes val caseIcon: Int, @DrawableRes val caseIcon: Int,
val casePercent: Float, val casePercent: Float,
val caseCharging: Boolean, val caseCharging: Boolean,
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
@DrawableRes val batteryIcon: Int, @DrawableRes val batteryIcon: Int,
val charging: Boolean, val charging: Boolean,
val worn: Boolean, val worn: Boolean,
val estimate: String?,
) : WidgetRenderState() ) : WidgetRenderState()
data class Message( data class Message(
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
leftPercent = 0.85f, leftPercent = 0.85f,
leftCharging = false, leftCharging = false,
leftInEar = true, leftInEar = true,
leftEstimate = "4h 55m",
rightIcon = R.drawable.device_airpods_pro2_right, rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f, rightPercent = 0.92f,
rightCharging = true, rightCharging = true,
rightInEar = false, rightInEar = false,
rightEstimate = "25m",
caseIcon = R.drawable.device_airpods_pro2_case, caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f, casePercent = 1.0f,
caseCharging = false, caseCharging = false,
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24, batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false, charging = false,
worn = true, 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,9 +5,11 @@ import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice 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.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
object WidgetRenderStateMapper { object WidgetRenderStateMapper {
@@ -19,6 +21,7 @@ object WidgetRenderStateMapper {
hasConfiguredProfile: Boolean, hasConfiguredProfile: Boolean,
profileLabel: String?, profileLabel: String?,
layout: BatteryLayout = BatteryLayout.NARROW, layout: BatteryLayout = BatteryLayout.NARROW,
estimate: BatteryEstimate? = null,
): WidgetRenderState { ): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme) val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme) val textColor = resolvedTextColor(context, theme)
@@ -43,15 +46,17 @@ object WidgetRenderStateMapper {
layout = layout, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon, leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(), leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true, leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true, leftInEar = device.isLeftInEar == true,
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
rightIcon = device.rightPodIcon, rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight.toBatteryFloat(), rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true, rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true, rightInEar = device.isRightInEar == true,
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
caseIcon = device.caseIcon, caseIcon = device.caseIcon,
casePercent = device.batteryCase.toBatteryFloat(), casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true, caseCharging = device.isCaseCharging == true,
) )
@@ -63,10 +68,11 @@ object WidgetRenderStateMapper {
layout = layout, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes, headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(), percent = device.batteryHeadset,
batteryIcon = getBatteryDrawable(device.batteryHeadset), batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true, charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true, worn = device.isBeingWorn == true,
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
) )
device != null -> WidgetRenderState.Message( device != null -> WidgetRenderState.Message(
@@ -96,6 +102,9 @@ object WidgetRenderStateMapper {
} }
} }
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
@ColorInt @ColorInt
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int { fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
val bgColor = theme.backgroundColor val bgColor = theme.backgroundColor
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
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.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.flow.replayingShare
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.toReactionConfig import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -75,6 +79,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(), reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile?.address in connectedAddresses, isSystemConnected = profile?.address in connectedAddresses,
) )
} }
@@ -86,7 +91,35 @@ class DeviceMonitor @Inject constructor(
connectedAddresses = connectedAddresses, connectedAddresses = connectedAddresses,
) )
}.onEach { liveState -> }.onEach { liveState ->
persistLiveDevices(liveState.liveDevices) persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
}
/**
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
* pod would never reach the cache even though the AAP socket is alive.
*/
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
return state.profiles.mapNotNull { profile ->
if (profile.id in coveredProfileIds) return@mapNotNull null
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
PodDevice(
profileId = profile.id,
label = profile.label,
ble = null,
aap = aap,
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
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,
)
}
} }
val devices: Flow<List<PodDevice>> = combine( val devices: Flow<List<PodDevice>> = combine(
@@ -150,6 +183,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(), reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in connectedAddresses, isSystemConnected = profile.address in connectedAddresses,
) )
} }
@@ -181,14 +215,40 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope) }.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>) { private suspend fun persistLiveDevices(devices: List<PodDevice>) {
for (device in devices) { for (device in devices) {
val profileId = device.profileId ?: continue val profileId = device.profileId ?: continue
val existing = deviceStateCache.cachedStates.value[profileId] try {
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
log(TAG, VERBOSE) { "Persisting state for $profileId" } log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState) deviceStateCache.save(profileId, newState)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
if (reportedPersistFailures.add(profileId)) {
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
}
}
} }
} }
@@ -231,6 +291,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled, profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask, profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(), reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
) )
} }
@@ -0,0 +1,150 @@
package eu.darken.capod.monitor.core
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Process
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.TimeSource
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.Logging.Priority.WARN
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.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identity of a monitoring session, persisted while the monitor runs (see
* [GeneralSettings.monitorSessionMark]). [pid] ties exit records to the exact process that was
* monitoring, [versionCode] filters out app-update kills (which report REASON_USER_REQUESTED
* before API 34).
*/
@Serializable
data class MonitorSessionMark(
val startedAt: Long,
val pid: Int,
val versionCode: Long,
)
/**
* Detects that the OS force-stopped us while the monitor was running (e.g. MIUI/HyperOS killing
* the app when the user clears recents), so the UI can point the user at vendor settings
* (autostart, battery restrictions, lock-in-recents).
*
* A force-stop can't be observed from inside the dying process: `onDestroy()`/`onTaskRemoved()`
* are skipped and `START_STICKY` restarts are suppressed for force-stopped apps. Instead,
* [GeneralSettings.monitorSessionMark] acts as a breadcrumb — set when monitoring starts, cleared
* on clean service destroy — and on the next monitor start the previous mark is checked against
* the system's [ApplicationExitInfo] records.
*/
@Singleton
class MonitorKillDetector @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val timeSource: TimeSource,
) {
/**
* Marks the monitor as running and evaluates whether the previous monitoring session was
* killed by the OS.
*
* The atomic swap makes this race-free against concurrent starts: a session that already
* restarted leaves a mark *newer* than any exit record, which never qualifies — a kill the
* system recovered from by itself needs no user-facing hint.
*/
suspend fun onMonitorStart() {
val current = MonitorSessionMark(
startedAt = timeSource.currentTimeMillis(),
pid = Process.myPid(),
versionCode = BuildConfigWrap.VERSION_CODE,
)
val previous = generalSettings.monitorSessionMark.update { current }.old
try {
evaluate(previous = previous, current = current)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Exit record evaluation failed: ${e.asLog()}" }
}
}
/** Clears the breadcrumb — call on clean service destroy. Skipped by OS force-stops. */
fun onMonitorCleanStopBlocking() {
generalSettings.monitorSessionMark.valueBlocking = null
}
// NewApi: lint can't follow the hasApiLevel(30) guard below.
@SuppressLint("NewApi")
private suspend fun evaluate(previous: MonitorSessionMark?, current: MonitorSessionMark) {
if (!hasApiLevel(30)) return
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val records = activityManager.getHistoricalProcessExitReasons(null, 0, MAX_RECORDS)
if (records.isEmpty()) return
val watermark = generalSettings.exitInfoWatermark.value()
val newRecords = records.filter { it.timestamp > watermark }
// Full record dumps end up in user debug logs — field data to verify/extend the
// vendor-specific reason codes we match on.
newRecords.forEach { log(TAG, INFO) { "New exit record: $it" } }
val killedAt = findOsKill(
records = newRecords.map { ExitRecord(reason = it.reason, timestamp = it.timestamp, pid = it.pid) },
previous = previous,
currentVersionCode = current.versionCode,
)
if (killedAt != null) {
log(TAG, WARN) { "OS killed the monitor at $killedAt (session was $previous)" }
generalSettings.lastOsKillAt.value(killedAt)
}
val newest = records.maxOf { it.timestamp }
if (newest > watermark) generalSettings.exitInfoWatermark.value(newest)
}
internal data class ExitRecord(val reason: Int, val timestamp: Long, val pid: Int)
companion object {
private const val MAX_RECORDS = 10
/**
* Newest exit record showing the OS killed us while the monitor was running, or null.
*
* Only [ApplicationExitInfo.REASON_USER_REQUESTED] counts: MIUI-style force-stops
* (recents-clear, security-app cleaners, settings force-stop) report it, while crashes,
* low-memory kills and self-stops must not trigger the hint. REASON_SIGNALED is deliberately
* excluded — LMKD SIGKILLs on ordinary low-memory devices would be false positives.
*
* A record must match the previous session's [MonitorSessionMark.pid] (so a swipe-kill of a
* later UI-only process isn't blamed on an old monitoring session) and postdate its start
* (which also dedups: once a new session's mark is written, older records can never match
* again). Records are ignored entirely when the app version changed, because before API 34
* package updates also killed the old process with REASON_USER_REQUESTED.
*/
internal fun findOsKill(
records: List<ExitRecord>,
previous: MonitorSessionMark?,
currentVersionCode: Long,
): Long? {
if (previous == null) return null
if (previous.versionCode != currentVersionCode) return null
return records
.filter { it.reason == ApplicationExitInfo.REASON_USER_REQUESTED }
.filter { it.pid == previous.pid }
.filter { it.timestamp > previous.startedAt }
.maxOfOrNull { it.timestamp }
}
private val TAG = logTag("Monitor", "KillDetector")
}
}
@@ -0,0 +1,80 @@
package eu.darken.capod.monitor.core
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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 javax.inject.Inject
/**
* Remedies for OS kills detected by [MonitorKillDetector]: vendor-specific "don't kill my app"
* instructions and, where the device has one (MIUI/HyperOS), the autostart permission screen.
*/
@Reusable
class MonitorKillGuidance @Inject constructor(
@ApplicationContext private val context: Context,
private val webpageTool: WebpageTool,
) {
/**
* Whether the MIUI/HyperOS autostart settings screen exists on this device.
* Requires the `com.miui.securitycenter` `<queries>` entry in the manifest — package
* visibility filtering makes this resolve to null otherwise on API 30+.
*/
val hasAutostartSettings: Boolean by lazy {
autostartIntent.resolveActivity(context.packageManager) != null
}
fun openKillInstructions() {
webpageTool.open(dontKillMyAppUrl(Build.MANUFACTURER))
}
fun openAutostartSettings() {
try {
context.startActivity(autostartIntent)
} catch (e: Exception) {
// ActivityNotFoundException on most builds, SecurityException on some MIUI versions.
log(TAG, WARN) { "Failed to open autostart settings: ${e.asLog()}" }
openKillInstructions()
}
}
private val autostartIntent: Intent
get() = Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity",
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {
internal fun dontKillMyAppUrl(manufacturer: String): String {
val slug = when (manufacturer.lowercase()) {
"xiaomi", "redmi", "poco" -> "xiaomi"
"huawei", "honor" -> "huawei"
"samsung" -> "samsung"
"oneplus" -> "oneplus"
"oppo" -> "oppo"
"realme" -> "realme"
"vivo", "iqoo" -> "vivo"
"meizu" -> "meizu"
"asus" -> "asus"
"sony" -> "sony"
"lenovo" -> "lenovo"
"motorola" -> "motorola"
else -> null
}
return if (slug != null) "https://dontkillmyapp.com/$slug" else "https://dontkillmyapp.com/"
}
private val TAG = logTag("Monitor", "KillGuidance")
}
}
@@ -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")
}
}
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo 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.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot 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.SingleBlePodSnapshot
@@ -59,6 +60,8 @@ data class PodDevice(
internal val profileLastRequestedListeningModeCycleMask: Int? = null, internal val profileLastRequestedListeningModeCycleMask: Int? = null,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */ /** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(), 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. */ /** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
val isSystemConnected: Boolean = false, val isSystemConnected: Boolean = false,
) { ) {
@@ -138,18 +141,24 @@ data class PodDevice(
} }
} }
// Battery — AAP preferred, BLE fallback, then cached // Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
val batteryLeft: Float? // for unknown to keep the type primitive; this is the boundary that previously emitted
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent // Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
val batteryLeft: Float
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
?: cached?.left?.percent ?: BATTERY_UNKNOWN
val batteryRight: Float? val batteryRight: Float
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
?: cached?.right?.percent ?: BATTERY_UNKNOWN
val batteryCase: Float? val batteryCase: Float
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
?: cached?.case?.percent ?: BATTERY_UNKNOWN
val batteryHeadset: Float? val batteryHeadset: Float
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
/** True when at least one displayed battery value was filled from cache (not live). */ /** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean val isBatteryCached: Boolean
@@ -174,18 +183,23 @@ data class PodDevice(
).minOrNull() ).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? 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? 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? 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? 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). // 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 // Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
@@ -205,18 +219,27 @@ data class PodDevice(
} }
} ?: (ble as? DualApplePods)?.primaryPod } ?: (ble as? DualApplePods)?.primaryPod
private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean =
this == AapSetting.EarDetection.PodPlacement.IN_EAR
private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? =
if (primaryPod == secondaryPod) primaryPod.isInEar() else null
// Ear detection — AAP preferred (lower latency), BLE fallback. // Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary. // AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
val isLeftInEar: Boolean? val isLeftInEar: Boolean?
get() { get() {
val earDetection = aap?.aapEarDetection val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) { if (earDetection != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) { if (primary != null) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR return if (primary == DualBlePodSnapshot.Pod.LEFT) {
} else { earDetection.primaryPod.isInEar()
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR } else {
earDetection.secondaryPod.isInEar()
}
} }
earDetection.samePlacementInEarOrNull()?.let { return it }
} }
return (ble as? HasEarDetectionDual)?.isLeftPodInEar return (ble as? HasEarDetectionDual)?.isLeftPodInEar
} }
@@ -225,12 +248,15 @@ data class PodDevice(
get() { get() {
val earDetection = aap?.aapEarDetection val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) { if (earDetection != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) { if (primary != null) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
} else { earDetection.primaryPod.isInEar()
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR } else {
earDetection.secondaryPod.isInEar()
}
} }
earDetection.samePlacementInEarOrNull()?.let { return it }
} }
return (ble as? HasEarDetectionDual)?.isRightPodInEar return (ble as? HasEarDetectionDual)?.isRightPodInEar
} }
@@ -248,6 +274,14 @@ data class PodDevice(
val isEitherPodInEar: Boolean? val isEitherPodInEar: Boolean?
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar 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? val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState get() = (ble as? DualApplePods)?.caseLidState
@@ -0,0 +1,31 @@
package eu.darken.capod.monitor.core
/**
* Connection tier rank used to sort devices by user-perceived "primary":
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* 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
isLive -> 1
else -> 2
}
/**
* Picks the user-perceived primary profiled device: lowest [tierRank], with
* the user's profile-list order as the tiebreaker.
*/
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
filter { it.profileId != null }
.minWithOrNull(
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
@@ -9,21 +9,22 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel 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.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -38,6 +39,21 @@ class AapAutoConnect @Inject constructor(
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>()) private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = 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( fun monitor(): Flow<Unit> = merge(
initialConnect(), initialConnect(),
reconnectOnDisconnect(), reconnectOnDisconnect(),
@@ -75,110 +91,150 @@ class AapAutoConnect @Inject constructor(
return 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})" } log(TAG) { "AAP connecting to $address (${profile.label})" }
try { try {
withTimeout(CONNECT_TIMEOUT) { aapManager.connect(address, bonded.internal!!, profile.model)
aapManager.connect(address, bonded.internal, profile.model)
}
log(TAG) { "AAP connected to $address" } log(TAG) { "AAP connected to $address" }
return
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" } log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) { for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs) 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() val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) { if (address !in currentConnected) {
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" } log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
break preReadyFailures.remove(address)
return
} }
val retryState = aapManager.allStates.value[address] val retryState = aapManager.allStates.value[address]
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) { if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP initial retry: $address already reconnected, stopping" } log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
break return
} }
try { try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" } log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) { aapManager.connect(address, bonded.internal!!, profile.model)
aapManager.connect(address, bonded.internal, profile.model)
}
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" } log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break return
} catch (retryException: Exception) { } catch (retryException: Exception) {
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" } 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 // Process each disconnect on its own child coroutine so a long per-address backoff never blocks
.onEach { address -> // another device's reconnect (the collector would otherwise serialise events). channelFlow gives
if (!activeReconnects.add(address)) { // us a scope to launch into; it intentionally emits nothing — it stays subscribed for its lifetime.
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" } private fun reconnectOnDisconnect(): Flow<Unit> = channelFlow<Unit> {
return@onEach aapManager.disconnectEvents.collect { event ->
} launch { handleReconnect(event) }
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" }
withTimeout(CONNECT_TIMEOUT) {
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)
}
} }
.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 private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } } .map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
@@ -218,7 +274,7 @@ class AapAutoConnect @Inject constructor(
val bonded = bluetoothManager.bondedDevices().first() val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address } .firstOrNull { it.address == address }
if (bonded != null) { if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel) aapManager.connect(address, bonded.internal!!, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" } log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -231,6 +287,12 @@ class AapAutoConnect @Inject constructor(
companion object { companion object {
private val TAG = logTag("Monitor", "AapAutoConnect") 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) internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
private val CONNECT_TIMEOUT = 5.seconds
/**
* 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.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp() is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown() 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.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND) is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic() 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"
}
}

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