Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Seed availability StateFlow with the persisted value via valueBlocking to close the cold-start race
2026-05-07 14:10:16 +02:00
Matthias Urhahn 1eeeb63a76 test: Cover nudge persistence, permission gating, and autolaunch reactivity 2026-05-07 14:10:16 +02:00
Matthias Urhahn a4ba302223 refactor(monitor): Compute monitor mode automatically from profile state 2026-05-07 14:10:16 +02:00
Matthias Urhahn 8c9094d85a ui(reaction): Tighten Auto play / Start music on wear descriptions 2026-05-07 14:08:22 +02:00
Matthias Urhahn 4529b107dd fix(reaction): Only auto-resume after ear-removal pause, not stem or sleep
Matches Apple's iOS/macOS behavior: stem-press pauses and sleep-detection pauses are explicit user intent, not eligible for auto-resume on next pod-in. sendPause gains a rememberForResume parameter (default false); only PlayPause's auto-pause branch passes true. New sendStop wrapper clears the auto-resume flag for stem-mapped MEDIA_STOP.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 17663bc759 refactor(reaction): Switch to sticky resume flag with opt-in cold-wear
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 278257998b fix(reaction): Don't auto-resume music after a user-initiated pause 2026-05-07 14:08:22 +02:00
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
darken 70d9432b2b Release: 5.1.1-rc0 2026-04-25 21:58:56 +02:00
darken 9d06839a1c General: Update app translations from Crowdin 2026-04-25 21:58:10 +02:00
darken 97f294e5f7 fix(widget): Persist theme settings across device reboot
Closes #549
2026-04-25 20:39:40 +02:00
darken a40599c1dc feat(settings): Tailor device settings to connection state
- Hide reactions and AAP sections unless device is classically connected

- Move advanced-settings-unavailable card to the bottom of the list

- Show 'device not nearby' infobox when out of range

- Show missing-paired-device banner with edit-profile action

- Replace pending banner with snackbar on user-initiated change
2026-04-25 19:00:19 +02:00
darken 175aef9e2f feat(logging): Log raw advertisement and decoded payload bytes 2026-04-25 16:19:21 +02:00
darken 36ae35918a refactor(widget): Move WidgetDeviceKey into widget package 2026-04-25 16:19:21 +02:00
darken 60ad1943b6 fix(widget): Gate refresh on display fields, drop log to VERBOSE 2026-04-25 16:19:21 +02:00
darken 5e727e907d refactor: Drop per-emission flow logger and redundant widget logs 2026-04-25 16:19:21 +02:00
darken 5360f54ea8 fix(privacy): Stop logging AirPods encryption keys 2026-04-25 16:19:21 +02:00
darken 486efe6e65 refactor: Reduce debug log noise from redundant state emissions 2026-04-25 16:19:21 +02:00
darken c2ff1f111e chore(reaction): Rename sleep notification channel label 2026-04-25 10:21:30 +02:00
darken 26b72726a7 feat(reaction): Pause media when AirPods detect sleep 2026-04-25 10:21:30 +02:00
darken f35fe968c6 docs(aap): Document SET_BAND_EDGES as RF band gating 2026-04-24 17:54:53 +02:00
darken 498f040a57 feat(aap): Drop experimental warning on Optimized Charge toggle
Verified on a real AirPods Pro 3: toggling flips pod charging state from CHARGING_OPTIMIZED to CHARGING and persists across reconnects. Apple-bool wire format is confirmed, so the 'experimental' warning box is no longer warranted.
2026-04-24 17:27:48 +02:00
darken 59b3106c3b feat(aap): Surface Optimized Charge indicator and Pro 3 toggle
Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B).

- Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false

- Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case

- Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING

- New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection)

- Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure
2026-04-24 17:27:48 +02:00
darken 89e8b83be4 docs(aap): Revise PME hypothesis to Personal Medical Equipment 2026-04-24 15:27:18 +02:00
darken f0531796dd feat(device-settings): Surface hardware, pending firmware, and bonded dates 2026-04-24 13:08:57 +02:00
darken a701fdfba7 feat(aap): Build a comprehensive AAP protocol catalog
Adopts the Wireshark AAP dissector (pabloaul/apple-wireshark) as a third reference source alongside LibrePods and MagicPodsCore. Catalogues every known message type and control/setting ID, corrects DeviceInfo field labels, and adds sealed AapPacket hierarchy with Connect Response parsing. Case Info probe (Pro 3), Sleep event, and Dynamic End of Charge decoders are in place for future use.
2026-04-24 08:39:46 +02:00
darken 118eaa8d08 feat(widget): Add 1x1 size support to battery widget
Introduce BatteryLayout enum with size-driven dispatch. Drops minWidth from 80dp to 40dp so the battery widget can be placed at one cell. At 1-cell-wide placements render a compact icon+percent stack; wider sizes keep the current NARROW/WIDE layouts.
2026-04-23 16:11:07 +02:00
darken 016914ec05 fix(overview): Replace red triangle with inline missing-pairing banner 2026-04-23 14:17:16 +02:00
darken 0aadec0b75 feat(device-settings): Link AAP-unavailable card to compatibility tracker 2026-04-23 12:53:24 +02:00
darken e6cf507937 chore(claude): Refresh .claude/ docs to match current architecture
Drops stale app-common/Wear OS references, renames migrated classes

(ReactionSettingsFragment/PopUpPodViewFactory/PodMonitor), documents

the BLE/AAP split in monitor/core, the layered AAP stack, Widget

(Glance) and Upgrade subsystems, Navigation3 + legacy helpers. Fixes

localization examples and screenshot pipeline counts.
2026-04-23 08:58:27 +02:00
884 changed files with 27182 additions and 4952 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

+5 -8
View File
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
## Project Structure
| Module | Description |
|--------|-------------|
| `app/` | Main Android app (FOSS and Google Play flavors) |
| `app-common/` | Shared code between phone and Wear OS apps |
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
## Build Flavors
@@ -20,10 +17,10 @@ Quick build check: `./gradlew assembleFossDebug`
| Path | Contains |
|------|----------|
| `app/src/main/java/` | Main app source (activities, fragments, services) |
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
| `app/src/main/res/` | Layouts, drawables, strings |
| `app-common/src/test/` | Unit tests |
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
| `app/build.gradle.kts` | App build config, dependencies, flavors |
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
@@ -31,7 +28,6 @@ Quick build check: `./gradlew assembleFossDebug`
## Development Tips
- Use `assembleFossDebug` as the fastest build variant for iteration
- Shared code goes in `app-common/`, app-specific code in `app/`
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
- Always use string resources for user-facing text (see localization rules)
- Check `git log --oneline -20` for commit message style before committing
@@ -47,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
- `pull-requests.md` — PR title and description conventions
- `agent-instructions.md` — Sub-agent delegation and critical thinking
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
+70 -31
View File
@@ -2,16 +2,22 @@
description: Architecture overview, module structure, key components, data flow, and dependencies
globs:
- "app/**/*.kt"
- "app-common/**/*.kt"
- "**/*.gradle.kts"
---
# Architecture
## Multi-Module Structure
## Single-Module Structure
- **app/**: Main Android application with FOSS and Google Play flavors
- **app-common/**: Shared code between main app and Wear OS app
One Gradle module: `app/`. Source sets:
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
- `debug` — debug-only code including screenshot content composables
- `test` / `testFoss` / `testGplay` — unit tests
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
## Core Patterns
@@ -22,18 +28,62 @@ globs:
## Key Components
### PodMonitor System
### Device Monitoring
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
- `MonitorControl`: Manages MonitorService lifecycle
- `MonitorService`: Foreground service that continuously scans for AirPods
- `BluetoothEventReceiver`: Handles system Bluetooth events
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
- `AapLifecycleManager` — starts/stops the AAP subsystem
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
**BLE vs AAP — what each path gives you:**
| | BLE (advertisements) | AAP (L2CAP session) |
|---|---|---|
| Direction | Read-only, passive | Bidirectional commands + events |
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
| Availability | Any pod in range | Only your own paired pods |
### Reaction System
- `ReactionSettingsFragment`: Configuration for popup notifications
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
- `PopUpWindow`: Displays AirPods status when case is opened
- `PopUpPodViewFactory`: Creates UI components for different pod models
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
### Widget System (Glance)
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
### Upgrade / Pro Features
- `UpgradeRepo` interface with two flavor implementations:
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
### AAP (Apple Accessory Protocol) Stack
Three-layer structure under `pods/core/apple/aap/`:
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
- **`engine/`** — session state machine for one connection:
- `AapConnection` — the L2CAP socket wrapper
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
### Common Utilities
@@ -54,31 +104,20 @@ globs:
## Data Flow
The app follows a unidirectional data flow:
1. `BluetoothEventReceiver` detects Bluetooth events
2. `MonitorService` scans for AirPods beacon data
3. `PodMonitor` processes and stores device information
4. ViewModels observe monitor data via repositories
5. UI components react to ViewModel state changes
6. `ReactionSystem` triggers popups and notifications
## Bluetooth LE Implementation
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
## Multi-Platform Considerations
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
## Testing Strategy
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
## Key Dependencies
- **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
- **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design**: UI components following Material Design guidelines
- **Material Design 3**: Compose Material3 UI components
+1
View File
@@ -21,6 +21,7 @@ Use the existing commit history as reference. Common prefixes:
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
- **feat**: New features
- **refactor**: Code restructuring without behavior change
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
- **chore**: Maintenance, dependency updates, build config
- **docs**: Documentation changes
+5 -3
View File
@@ -9,7 +9,7 @@ globs:
When adding new user-facing strings:
- **Always use string resources**: Never hardcode user-facing text in layouts or code
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`)
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
- **Provide context**: String names should indicate usage and location
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
@@ -17,5 +17,7 @@ When adding new user-facing strings:
- `profiles_create_title` (screen title)
- `profiles_name_label` (form field label)
- `profiles_delete_confirmation` (dialog message)
- `error_network_unavailable` (error message)
- `profiles_name_default` (default value)
- `troubleshooter_ble_result_failure_title` (status/error title)
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
+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`.
+39 -12
View File
@@ -19,25 +19,33 @@ ScreenshotContent.kt (mock data + composables)
| File | Purpose |
|------|---------|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations |
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales |
| `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 |
## 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
```bash
# Full run — all 68 locales, ~12 batches, ~7 minutes
./fastlane/generate_screenshots.sh
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# Use this for local iteration and PRs that touch screenshot content.
./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)
./fastlane/copy_screenshots.sh
# Clean copy (removes old screenshots first)
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
./fastlane/copy_screenshots.sh --clean
```
@@ -47,20 +55,39 @@ ScreenshotContent.kt (mock data + composables)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
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)
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
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
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/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## 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
./fastlane/generate_screenshots.sh
./fastlane/generate_screenshots.sh --smoke
./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
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~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)
- Gradle daemon is stopped between batches to release memory
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
+1 -10
View File
@@ -30,13 +30,4 @@ runs:
~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: |
${{ 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-
${{ runner.os }}-gradle-caches-
+27 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
@@ -78,4 +81,27 @@ jobs:
with:
persist-credentials: false
- 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:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- 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:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
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:
needs: validate-tag
name: Create GitHub release
permissions:
contents: write
actions: write
runs-on: ubuntu-22.04
environment: foss-production
steps:
@@ -61,7 +104,7 @@ jobs:
- name: Create pre-release
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:
prerelease: true
tag_name: ${{ github.ref_name }}
@@ -73,7 +116,7 @@ jobs:
- name: Create release
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:
prerelease: false
tag_name: ${{ github.ref_name }}
@@ -83,10 +126,19 @@ jobs:
env:
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:
needs: validate-tag
name: Create Google Play release
runs-on: ubuntu-22.04
environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps:
- name: Decode Keystore
env:
@@ -122,6 +174,10 @@ jobs:
with:
ruby-version: 3.3.6
bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
- name: Assemble beta and upload to Google Play
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+14 -1
View File
@@ -17,5 +17,18 @@
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
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
protocol-research/
protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
+1 -2
View File
@@ -81,8 +81,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
## Thanks to
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -1 +1 @@
5.1.0-rc0 50100000
5.2.1-rc0 50201000
-3
View File
@@ -20,9 +20,6 @@ exclude:
- buildSrc
- gradle/wrapper
- fastlane
- Gemfile
- Gemfile.lock
- crowdin*
- app
- app-common
- CONTRIBUTING.md
+1
View File
@@ -130,6 +130,7 @@ android {
//noinspection WrongGradleMethod
tasks.withType<Test> {
useJUnitPlatform()
maxHeapSize = "4g"
}
}
}
@@ -14,17 +14,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
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.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
@@ -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"
@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(
state = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
devices = devices,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -55,6 +91,7 @@ internal fun DashboardContent() = PreviewWrapper {
),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -83,19 +120,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
@Composable
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(
state = DeviceProfileCreationViewModel.State(
isEditMode = false,
name = "",
name = "My AirPods Pro",
nameError = null,
selectedModel = null,
selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null,
encryptionKey = null,
selectedDevice = null,
bondedDeviceItems = emptyList(),
selectedDevice = pairedAirPods,
bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f,
canSave = false,
canSave = true,
),
onBack = {},
onSave = {},
@@ -118,6 +174,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
now = MOCK_NOW,
isPro = true,
isNudgeAvailable = true,
isClassicallyConnected = true,
),
onNavigateUp = {},
)
@@ -199,7 +256,7 @@ internal fun HomescreenWidgetContent() {
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
),
).withLocalizedPreviewEstimates(LocalContext.current),
)
}
}
@@ -211,7 +268,7 @@ private fun PreviewDashboardLight() = DashboardContent()
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable
private fun PreviewDashboardDark() = DashboardContent()
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable
+7 -7
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
</resources>
+2 -2
View File
@@ -5,7 +5,7 @@
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez sekšanas. Bez Google Play saistībām.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Jūsu atbalsts uztur CAPod pie dzīvības.</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+8 -8
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
<string name="upgrade_badge_label">احترافي</string>
<string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
<string name="upgrade_badge_label">النسخة الاحترافية</string>
</resources>
+3 -3
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmantojat pareizo kontu?</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmanto pareizo kontu?</string>
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūdzu, mēģiniet vēlreiz vēlāk vai pārstartējiet tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūgums mēģināt vēlreiz vēlāk vai pārsāknēt tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play jūsu pirkuma informāciju. Notīriet Google Play kešatmiņu un pārstartējiet tālruni.\n\nKļūda %s</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play pirkuma informāciju. Jāiztīra Google Play kešatmiņa un pārsāknē tālrunis.\n\nKļūda %s</string>
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
<string name="upgrade_badge_label">Pro</string>
</resources>
+15 -1
View File
@@ -141,6 +141,20 @@
android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice"
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>
</manifest>
</manifest>
+33 -30
View File
@@ -1,33 +1,37 @@
package eu.darken.capod
import android.app.Application
import android.os.Looper
import dagger.hilt.android.HiltAndroidApp
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.logging.LogCatLogger
import eu.darken.capod.common.debug.logging.Logging
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.WARN
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp
open class App : Application() {
@@ -36,25 +40,26 @@ open class App : Application() {
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
var foregroundExceptionHandled = false
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
foregroundExceptionHandled = true
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
Looper.loop()
return@setDefaultUncaughtExceptionHandler
}
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" }
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
}
Thread.setDefaultUncaughtExceptionHandler(
CapodUncaughtExceptionHandler(
previousHandler = oldHandler,
cancelBeforeDelegate = { throwable ->
// Best-effort shutdown: the system handler may terminate the process immediately,
// but cancellation can still close sockets if it gets a scheduling window.
if (::appScope.isInitialized) {
appScope.cancel(CancellationException("Uncaught exception", throwable))
}
},
)
)
autoReporting.setup(this)
@@ -62,11 +67,18 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles()
.distinctUntilChanged()
combine(
deviceMonitor.devicesWithProfiles(),
batteryEstimator.estimates,
) { devices, estimates -> devices to estimates }
// Estimate display values can change while the device key is stable (e.g. a charge
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
.distinctUntilChangedBy { (devices, estimates) ->
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
}
.throttleLatest(1000)
.onEach {
log(TAG) { "Main device changed, refreshing widgets." }
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
widgetManager.refreshWidgets()
}
.launchIn(appScope)
@@ -83,14 +95,5 @@ open class App : Application() {
companion object {
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
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
@@ -14,37 +17,103 @@ class MediaControl @Inject constructor(
private val audioManager: AudioManager,
private val timeSource: TimeSource,
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
/**
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
* dispatches a resume or when music transitions inactive→active from any source. Read by
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
*
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
* pod-out conversations and had a known race where music starting again via another source
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
*/
@Volatile private var capPaused: Boolean = false
@Volatile private var lastKnownMusicActive: Boolean = false
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
val nowActive = audioManager.isMusicActive
if (!lastKnownMusicActive && nowActive) {
// Music started by some source (could be us via sendPlay or someone else).
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
// re-fire a play key over already-active music.
capPaused = false
}
lastKnownMusicActive = nowActive
}
}
init {
// Seed the active flag from current state so we won't miss the next inactive→active
// transition if music is already playing when MediaControl is constructed.
lastKnownMusicActive = audioManager.isMusicActive
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
}
val isPlaying: Boolean
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
get() = capPaused
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
if (audioManager.isMusicActive && !capPaused) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause()
capPaused = false
}
suspend fun sendPause() {
log(TAG, INFO) { "sendPause()" }
/**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
*
* Returns `true` when a key event was actually dispatched, `false` when the call was a
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
* a pending auto-resume from a recent ear-removal pause). Callers that need to
* distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
* reaction, which gates its notification and cooldown on a real pause — should branch
* on the return value rather than checking [isPlaying] themselves to avoid a
* check-then-act race with the audio system.
*/
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return
return false
}
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
// playback callback that fires during the dispatch (e.g. a fast user resume on the
// phone, or another app grabbing audio focus and immediately starting) could clear
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
// This single explicit assignment also covers the contract that an explicit user
// pause cancels a pending auto-resume.
capPaused = rememberForResume
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
}
/**
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
*/
suspend fun sendStop() {
log(TAG, INFO) { "sendStop()" }
capPaused = false
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) {
if (capPaused) {
sendPlay()
return
}
@@ -90,16 +159,71 @@ class MediaControl @Inject constructor(
)
}
private fun markRecentCapPause() {
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
/**
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
* the prior + the volume actually applied, so the caller can later restore it and detect whether
* the user changed the volume in the meantime.
*
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
* frequent push event and the volume panel flashing would be noisy. The applied target is read
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
*/
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
return null
}
if (audioManager.isVolumeFixed) {
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
return null
}
val percent = reductionPercent.coerceIn(0, 100)
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
} else {
0
}
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
if (target >= prior) {
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
return null
}
return try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
VolumeDuck(priorVolume = prior, appliedVolume = applied)
} catch (e: SecurityException) {
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
null
}
}
private fun clearRecentCapPause() {
capPauseExpiryElapsedRealtime = 0L
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
fun restoreMusicVolume(priorVolume: Int) {
if (audioManager.isVolumeFixed) return
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
} catch (e: SecurityException) {
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
}
}
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
data class VolumeDuck(
val priorVolume: Int,
val appliedVolume: Int,
)
companion object {
private val TAG = logTag("MediaControl")
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
}
}
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
.sortedBy { it.key }
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
.ifEmpty { "-" }
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
}
@JvmName("logBleScanResultCollectionSummary")
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
.sorted()
.joinToString(separator = ",")
.ifEmpty { "-" }
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
}
@JvmName("logFrameworkScanResultCollectionSummary")
@@ -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.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -32,7 +33,6 @@ import javax.inject.Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
private val timeSource: TimeSource,
) {
@@ -113,22 +113,7 @@ class BleScanner @Inject constructor(
null
}
val flushJob = if (!disableDirectScanCallback) {
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
}
var flushJob: Job? = null
val filterList = when {
useOffloadedFiltering -> filters.toList()
@@ -167,31 +152,59 @@ class BleScanner @Inject constructor(
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
try {
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
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)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "startScan() denied: ${e.message}" }
forwarderConsumer?.cancel()
close(e)
return@callbackFlow
}
awaitClose {
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
try {
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
} catch (e: SecurityException) {
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
}
log(TAG) { "BleScanner stopped" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
import java.time.Instant
data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val address: BluetoothAddress,
val name: String?,
val seenFirstAt: Instant,
) {
val address: BluetoothAddress
get() = internal.address
val name: String?
get() = internal.name
}
internal val internal: BluetoothDevice? = null,
)
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -23,6 +24,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
@@ -255,6 +257,8 @@ class BluetoothManager2 @Inject constructor(
}
.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -304,6 +308,8 @@ class BluetoothManager2 @Inject constructor(
val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -318,8 +324,73 @@ class BluetoothManager2 @Inject constructor(
emit(wrappedDevices)
}
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
@android.annotation.SuppressLint("MissingPermission")
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
@@ -340,9 +411,10 @@ class BluetoothManager2 @Inject constructor(
@android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try {
val target = device.internal ?: return false
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.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" }
result
} catch (e: Exception) {
@@ -352,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
}
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (isSecurityException) {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
_isNudgeAvailable = false
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (!isSecurityException) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
return@map NudgeAttemptResult.Rejected
}
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
// versions a SecurityException can't be a missing-permission failure for that
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
// should persist BROKEN.
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
!Permission.BLUETOOTH_CONNECT.isGranted(context)
if (missingBtConnect) {
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
NudgeAttemptResult.UnavailableMissingPermission
} else {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
NudgeAttemptResult.UnavailableHiddenApi
}
}
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
@@ -1,103 +0,0 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import javax.inject.Inject
import kotlin.random.Random
import eu.darken.capod.common.datastore.valueBlocking
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
) {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.valueBlocking) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
val now = timeSource.now()
val generatedAt = timeSource.elapsedRealtimeNanos()
// AirPods Gen1
BleScanResult(
receivedAt = now,
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen2
BleScanResult(
receivedAt = now,
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen3
BleScanResult(
receivedAt = now,
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Max
BleScanResult(
receivedAt = now,
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
// BeatsFlex
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
// Tws i99999
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
// Unknown Device
BleScanResult(
receivedAt = now,
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.common.bluetooth
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class NudgeAvailability {
@SerialName("nudge.unknown") UNKNOWN,
@SerialName("nudge.available") AVAILABLE,
@SerialName("nudge.broken") BROKEN,
}
enum class NudgeAttemptResult {
Accepted,
Rejected,
UnavailableHiddenApi,
UnavailableMissingPermission,
}
@@ -0,0 +1,59 @@
package eu.darken.capod.common.bluetooth
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NudgeCapabilityStore @Inject constructor(
generalSettings: GeneralSettings,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
) {
private val persistedValue = generalSettings.nudgeAvailability
// Resolve the persisted value synchronously at construction so consumers that read
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.Eagerly,
initialValue = persistedValue.valueBlocking,
)
fun record(result: NudgeAttemptResult) {
val verdict = verdictFor(result) ?: return
if (availability.value == verdict) return
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
appScope.launch(dispatcherProvider.IO) {
persistedValue.value(verdict)
}
}
companion object {
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
NudgeAttemptResult.Rejected,
NudgeAttemptResult.UnavailableMissingPermission -> null
}
}
}
@@ -1,25 +1,11 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@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
),
}
enum class ScannerMode {
@SerialName("scanner.mode.lowpower") LOW_POWER,
@SerialName("scanner.mode.balanced") BALANCED,
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
}
@@ -180,18 +180,21 @@ object MockPodDataProvider {
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
label = "My AirPods Pro",
ble = airPodsProMixed(),
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
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. */
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
profileId = "preview-dual-reactions",
@@ -227,12 +261,14 @@ object MockPodDataProvider {
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
label = "AirPods Max",
ble = airPodsMax(),
aap = null,
)
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap",
label = "AirPods Max",
ble = airPodsMaxCharging(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
@@ -255,11 +291,42 @@ object MockPodDataProvider {
aap = null,
)
/** Dual pods matched to a profile that has no paired Bluetooth device selected. */
fun dualPodMissingPairedDevice(): PodDevice = PodDevice(
profileId = "preview-dual-missing-paired",
ble = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
batteryRightPodPercent = 0.45f,
_batteryCasePercent = 0.60f,
_isLeftPodCharging = true,
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
),
aap = null,
)
/** Single pod matched to a profile that has no paired Bluetooth device selected. */
fun singlePodMissingPairedDevice(): PodDevice = PodDevice(
profileId = "preview-single-missing-paired",
ble = MockSingleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.85f,
_isBeingWorn = true,
),
aap = null,
)
/** Cached-only dual pod — device fully offline, showing last known state. */
fun dualPodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached",
ble = null,
aap = null,
profileAddress = "AA:BB:CC:DD:EE:FF",
profileModel = PodModel.AIRPODS_PRO2,
cached = CachedDeviceState(
profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2,
@@ -423,7 +490,6 @@ private class MockSingleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -462,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
) {
val keyName: String get() = key.name
val flow: Flow<T> = dataStore.data.map { prefs ->
reader(prefs[key])
}
val flow: Flow<T> = dataStore.data
.map { prefs -> prefs[key] }
.distinctUntilChanged()
.map { raw -> reader(raw) }
data class Updated<T>(val old: T, val new: T)
@@ -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.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.asLogSummary
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.MutableStateFlow
object Bugs {
var reporter: AutomaticBugReporter? = null
/**
* True while the debug log recorder is actively recording. Surfaces extra
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
* to add support for new headphones can capture useful data without a
* permanent debug toggle.
*/
val isDebug = MutableStateFlow(false)
fun report(
tag: String,
message: String,
exception: Throwable
) {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
reporter?.notify(exception) ?: run {
log(TAG, WARN) { "Bug tracking not initialized yet." }
val bugReporter = reporter
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")
}
}
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
}
@@ -61,16 +61,19 @@ object Logging {
message: String
) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot
.filter { it.isLoggable(priority) }
.forEach {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
snapshot.forEach {
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
if (isLoggable) {
runCatching {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
}
}
}
fun clearAll() {
@@ -110,14 +113,28 @@ inline fun log(
}
}
fun Throwable.asLog(): String {
fun Throwable.asLog(): String = runCatching {
val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter)
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
internal fun Any.logTagViaCallSite(): String {
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.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
}
}
.launchIn(appScope)
internalState.flow
.onEach { Bugs.isDebug.value = it.isRecording }
.launchIn(appScope)
}
private fun createSessionDir(): File {
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.shareIn
@@ -54,7 +53,6 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
.catch {
if (it.hasCause(CancellationException::class)) {
@@ -36,9 +36,6 @@ object Nav {
@Serializable
data object General : Settings
@Serializable
data object Debug : Settings
@Serializable
data object Support : Settings
@@ -1,11 +1,10 @@
package eu.darken.capod.common.permissions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel
@@ -18,7 +17,7 @@ enum class Permission(
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
},
) {
BLUETOOTH(
@@ -87,4 +86,4 @@ enum class Permission(
fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context)
}
}
@@ -9,7 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod
@@ -37,15 +37,11 @@ class GeneralSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
val keepConnectedNotificationAfterDisconnect =
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 oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
@@ -77,8 +73,18 @@ class GeneralSettings @Inject constructor(
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
val nudgeAvailability = dataStore.createValue(
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
onErrorFallbackToDefault = true,
)
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -1,21 +1,13 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class MonitorMode(
@StringRes val labelRes: Int
val intensity: Int,
) {
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
}
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
@@ -21,7 +22,7 @@ import javax.inject.Singleton
@Singleton
class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val monitorModeResolver: MonitorModeResolver,
private val profilesRepo: DeviceProfilesRepo,
) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger,
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
anyPopupEnabled,
) { _, monitorMode, showPopUp ->
Permission.entries
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
.filter { isApplicable(it, monitorMode, showPopUp) }
.filter { it.isRequired(context) }
.toSet()
}
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
companion object {
private val TAG = logTag("PermissionTool")
/**
* Whether [permission] is applicable for the user's current configuration.
* Three permissions are conditional on monitor mode or popup usage:
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
* Everything else is unconditionally applicable.
*/
internal fun isApplicable(
permission: Permission,
monitorMode: MonitorMode,
anyPopupEnabled: Boolean,
): Boolean = when (permission) {
Permission.IGNORE_BATTERY_OPTIMIZATION,
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
else -> true
}
}
}
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
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.Nav
import eu.darken.capod.common.navigation.NavigationController
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
@Inject lateinit var popUpWindow: PopUpWindow
@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?) {
super.onCreate(savedInstanceState)
installSplashScreen()
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
navCtrl.setup(backStack)
LaunchedEffect(Unit) {
// Cold-start intent (the activity's launching 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) {
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
override fun onNewIntent(intent: 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?) {
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.components
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
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
}
@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
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -41,14 +42,18 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
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.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
@@ -60,7 +65,13 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun DeviceSettingsScreenHost(
@@ -78,6 +89,19 @@ fun DeviceSettingsScreenHost(
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
val hasPendingSettings = state?.device?.hasPendingSettings
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(hasPendingSettings) {
val current = hasPendingSettings ?: return@LaunchedEffect
val previous = lastPendingState
lastPendingState = current
if (previous == false && current) {
snackbarHostState.showSnackbar(pendingInfoMessage)
}
}
LaunchedEffect(Unit) {
vm.events.collect { event ->
@@ -99,6 +123,10 @@ fun DeviceSettingsScreenHost(
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
snackbarHostState.showSnackbar(offRejectedMessage)
}
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
}
}
}
}
@@ -123,6 +151,8 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onConversationActionChange = { vm.setConversationAction(it) },
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
@@ -133,19 +163,27 @@ fun DeviceSettingsScreenHost(
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
onSleepDetectionChange = { vm.setSleepDetection(it) },
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
onDeviceNameChange = { vm.setDeviceName(it) },
onPressControlsClick = { vm.navToPressControls() },
onEditProfile = { vm.navToEditProfile() },
onForceConnect = { vm.forceConnect() },
onUpgrade = { vm.launchUpgrade() },
onOnePodModeChange = { vm.setOnePodMode(it) },
onAutoPlayChange = { vm.setAutoPlay(it) },
onAutoPauseChange = { vm.setAutoPause(it) },
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
onAutoConnectChange = { vm.setAutoConnect(it) },
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
onChargedThresholdChange = { vm.setChargedThreshold(it) },
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
)
}
@@ -158,6 +196,8 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
@@ -168,19 +208,27 @@ fun DeviceSettingsScreen(
onListeningModeCycleChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {},
onPressControlsClick: () -> Unit = {},
onEditProfile: () -> Unit = {},
onForceConnect: () -> Unit = {},
onUpgrade: () -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
onResetBatteryEstimate: () -> Unit = {},
) {
val device = state.device
val features = device?.model?.features
@@ -237,86 +285,35 @@ fun DeviceSettingsScreen(
device.firstSeenFormatted(state.now)
} else null
val info = device.deviceInfo
val detailItems = buildList<DeviceDetailItem> {
if (info != null) {
if (info.manufacturer.isNotBlank()) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_manufacturer_label),
info.manufacturer
)
)
}
if (info.serialNumber.isNotBlank()) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_serial_label),
info.serialNumber
)
)
}
val hasFirmware = info.firmwareVersion.isNotBlank()
val hasBuild = !info.buildNumber.isNullOrBlank()
if (hasFirmware && hasBuild) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
),
)
)
} else if (hasFirmware) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
)
)
} else if (hasBuild) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
)
)
}
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
if (hasLeft && hasRight) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
),
)
)
} else if (hasLeft) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
)
)
} else if (hasRight) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
)
)
}
}
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailItems = buildDeviceInfoDetailItems(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
@@ -331,58 +328,89 @@ fun DeviceSettingsScreen(
}
}
// Not connected info — BLE live but no AAP connection
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) {
if (state.isClassicallyConnected) {
// Device is connected for audio but AAP isn't available — show passive info
item("aap_unavailable_info") {
AapUnavailableCard()
}
} else {
// Device is nearby but not connected — prompt user to connect
item("not_connected_info") {
NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable,
isForceConnecting = state.isForceConnecting,
onConnect = onForceConnect,
)
}
// Profile has no paired Bluetooth device — supersedes all other state cards
if (device != null && !device.hasSelectedPairedDevice) {
item("missing_paired_device") {
MissingPairedDeviceBanner(
onClick = onEditProfile,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// ── Reactions (per-profile, not gated on AAP) ─────────────────
if (device != null && features != null) {
// Not nearby — no live BLE; settings require the device to be present
if (device != null && device.hasSelectedPairedDevice &&
device.ble == null && !state.isClassicallyConnected
) {
item("not_nearby_info") {
SettingsInfoBox(
title = stringResource(R.string.device_settings_not_nearby_label),
text = stringResource(R.string.device_settings_not_nearby_description),
)
}
}
// Not connected info — device is nearby but not connected; prompt user to connect
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
!state.isClassicallyConnected
) {
item("not_connected_info") {
NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable,
isForceConnecting = state.isForceConnecting,
onConnect = onForceConnect,
)
}
}
// ── Reactions (gated on classic connection — needs phone to be the audio target) ──
if (device != null && device.hasSelectedPairedDevice &&
features != null && state.isClassicallyConnected
) {
item("reactions_section") {
ReactionsCard(
device = device,
features = features,
isPro = isPro,
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange,
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onConversationActionChange = onConversationActionChange,
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onFixMonitorMode = onFixMonitorMode,
onOpenIssueTracker = onOpenIssueTracker,
)
}
}
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected) {
if (device.hasPendingSettings == true) {
item("pending_info") {
SettingsInfoBox(
text = stringResource(R.string.device_settings_pending_info),
)
}
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
if (device != null && features != null && device.isLive) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
isPro = isPro,
chargeCapControlEnabled = enabled,
estimateEnabled = state.batteryEstimateEnabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
onChargedThresholdChange = onChargedThresholdChange,
onChargedSlotScopeChange = onChargedSlotScopeChange,
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
onResetEstimate = onResetBatteryEstimate,
)
}
}
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
// ── Noise Control ────────────────────────────
if (features.hasAncControl && device.ancMode != null) {
@@ -412,7 +440,7 @@ fun DeviceSettingsScreen(
val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) ||
(features.hasToneVolume && toneVol != null) ||
features.hasMicrophoneMode
(features.hasMicrophoneMode && device.microphoneMode != null)
if (showSoundSection) {
item("sound_section") {
SoundCard(
@@ -430,7 +458,7 @@ fun DeviceSettingsScreen(
}
// ── Controls ─────────────────────────────────
val showControlsSection = features.hasStemConfig ||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
@@ -462,13 +490,22 @@ fun DeviceSettingsScreen(
}
}
// EQ visualization (debug only)
val eqBands = device.eqBands
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) {
// PME Config visualization (debug only, opcode 0x53).
// PME = Personal Medical Equipment (hearing-aid band gains for iOS
// 18.1+ AirPods Pro 2 hearing-aid mode). The bar chart reuses the
// EQ visualization because the data shape (per-ear × per-profile ×
// band gains) renders identically; hidden on all-zero payloads, which
// just mean the user hasn't configured a hearing-aid profile yet.
val pmeConfig = device.pmeConfig
if (eu.darken.capod.BuildConfig.DEBUG &&
pmeConfig != null &&
pmeConfig.sets.isNotEmpty() &&
!pmeConfig.isAllZero
) {
item("eq_section") {
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
EqBarsChart(
sets = eqBands.sets,
sets = pmeConfig.sets,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@@ -476,6 +513,16 @@ fun DeviceSettingsScreen(
}
}
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
state.isClassicallyConnected
) {
item("aap_unavailable_info") {
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
}
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
}
@@ -496,6 +543,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454624",
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
),
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
@@ -520,11 +573,13 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
),
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true),
),
),
),
now = MOCK_NOW,
isPro = isPro,
isClassicallyConnected = true,
)
@Preview2
@@ -590,3 +645,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = MockPodDataProvider.dualPodMissingPairedDevice(),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -16,10 +18,14 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.BatteryHealth
import eu.darken.capod.monitor.core.battery.DrainProfile
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -29,6 +35,8 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,7 +59,10 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val batteryEstimator: BatteryEstimator,
private val drainStore: BatteryDrainStore,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
@@ -79,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor(
data class SendFailed(val command: AapCommand, val message: String?) : Event
data object SystemRenameUnavailable : Event
data object OffModeRejectedByDevice : Event
data object DynamicEndOfChargeRejectedByDevice : Event
}
val events = SingleEventFlow<Event>()
@@ -91,6 +103,16 @@ class DeviceSettingsViewModel @Inject constructor(
}
}
}
launch {
aapManager.settingRejectedEvents.collect { (address, command) ->
if (address != currentAddress()) return@collect
when (command) {
is AapCommand.SetDynamicEndOfCharge ->
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
}
}
}
}
val state = targetProfileId.flatMapLatest { profileId ->
@@ -103,8 +125,10 @@ class DeviceSettingsViewModel @Inject constructor(
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
// Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
nudgeCapabilityStore.availability,
drainStore.profiles,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info
@@ -116,9 +140,13 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
val nudgeAvailability = args[7] as NudgeAvailability
@Suppress("UNCHECKED_CAST")
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
?.stemActions
val stemActions = appleProfile?.stemActions
val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr ->
try {
@@ -131,7 +159,7 @@ class DeviceSettingsViewModel @Inject constructor(
device = device,
now = timeSource.now(),
isPro = upgrade.isPro,
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing,
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode,
@@ -140,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true,
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
// Health rides on the same learned data as the estimate — the per-device toggle
// governs both.
batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
// A model with a rating WILL eventually produce a health figure — surface the
// feature as "still determining" until the listening sessions accumulate. Without
// a paired device the listening gate can never open, so no promise is made.
batteryHealthPending = device != null &&
(appleProfile?.batteryEstimateEnabled ?: true) &&
device.model.batterySpec != null &&
device.hasSelectedPairedDevice,
)
}
}.asLiveState()
@@ -165,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
val batteryHealth: BatteryHealth.PerPod? = null,
/** True when health CAN be derived for this device (rated model, feature on) — shows the
* "still determining" placeholder while [batteryHealth] is null. */
val batteryHealthPending: Boolean = false,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -195,18 +242,19 @@ class DeviceSettingsViewModel @Inject constructor(
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
if (!bluetoothManager.isNudgeAvailable) {
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
val accepted = try {
val result = try {
bluetoothManager.nudgeConnection(bonded)
} catch (e: Exception) {
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
false
NudgeAttemptResult.Rejected
}
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
if (!accepted) {
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
nudgeCapabilityStore.record(result)
if (result != NudgeAttemptResult.Accepted) {
events.tryEmit(Event.OpenBluetoothSettings)
}
} finally {
@@ -283,7 +331,16 @@ class DeviceSettingsViewModel @Inject constructor(
}
}
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
fun setSleepDetection(enabled: Boolean) = launch {
log(TAG, INFO) { "setSleepDetection($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
sendInternal(AapCommand.SetSleepDetection(enabled))
}
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
fun setDeviceName(name: String) = launch {
val address = currentAddress() ?: return@launch
@@ -362,6 +419,15 @@ class DeviceSettingsViewModel @Inject constructor(
syncEarDetection(autoPause = enabled)
}
fun setStartMusicOnWear(enabled: Boolean) = launch {
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(startMusicOnWear = enabled) }
}
/**
* Keeps the device-side Automatic Ear Detection setting in sync with the
* auto-play / auto-pause reaction toggles. When either reaction is active
@@ -385,15 +451,6 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoConnect(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoConnect($enabled)" }
updateProfileNow { it.copy(autoConnect = enabled) }
if (enabled) {
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
// nothing is connected. We intentionally do NOT revert on disable — the user
// may have set ALWAYS manually, or another profile may still need it.
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
}
}
}
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
@@ -411,17 +468,71 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
fun setMonitorModeAutomatic() = launch {
log(TAG, INFO) { "setMonitorModeAutomatic()" }
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
fun setNotifyWhenCharged(enabled: Boolean) {
log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
proGatedReaction(enabled) { it.copy(notifyWhenCharged = enabled) }
}
fun setChargedSlotScope(scope: ChargedSlotScope) = launch {
log(TAG, INFO) { "setChargedSlotScope($scope)" }
updateProfileNow { it.copy(chargedSlotScope = scope) }
}
fun setChargedThreshold(percent: Int) = launch {
log(TAG, INFO) { "setChargedThreshold($percent)" }
val snapped = (percent.toFloat() / ReactionConfig.CHARGED_THRESHOLD_STEP)
.let { Math.round(it) * ReactionConfig.CHARGED_THRESHOLD_STEP }
.coerceIn(ReactionConfig.MIN_CHARGED_THRESHOLD, ReactionConfig.MAX_CHARGED_THRESHOLD)
updateProfileNow { it.copy(chargedThreshold = snapped) }
}
fun setConversationAction(action: ConversationAction) = launch {
log(TAG, INFO) { "setConversationAction($action)" }
// The action picker is only shown while Conversation Awareness is already enabled (the pod
// emits no speaking frames otherwise), so no need to auto-enable it here.
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(conversationAction = action) }
}
fun setConversationVolumeReduction(percent: Int) = launch {
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
}
// ── Battery estimate (per-profile) ───────────────────────────────────────
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
}
fun resetBatteryEstimate() = launch {
val profileId = targetProfileId.value ?: return@launch
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
batteryEstimator.reset(profileId)
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch
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))
}
fun navToEditProfile() = launch {
log(TAG, INFO) { "navToEditProfile()" }
val profileId = targetProfileId.value ?: return@launch
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
}
fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade)
@@ -431,6 +542,10 @@ class DeviceSettingsViewModel @Inject constructor(
webpageTool.open("https://github.com/d4rken-org/capod/issues")
}
fun openAapCompatibilityTracker() {
webpageTool.open("https://github.com/d4rken-org/capod/issues/538")
}
companion object {
private val TAG = logTag("DeviceSettings", "VM")
private const val OFF_BIT = 0x01
@@ -9,7 +9,9 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
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
@@ -18,7 +20,9 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun AapUnavailableCard() {
internal fun AapUnavailableCard(
onOpenTracker: () -> Unit = {},
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -36,6 +40,13 @@ internal fun AapUnavailableCard() {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onOpenTracker,
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.device_settings_aap_unavailable_action))
}
}
}
}
@@ -0,0 +1,205 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.NotificationsActive
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
/**
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
* battery health lead, then a divider, then the charging-side settings:
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
* it starts over from the model's rated battery life.
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
* works for any live device (BLE or AAP), not just when the phone is the audio source.
*
* Each row is gated independently; the whole card hides when nothing applies.
*/
@Composable
internal fun BatteryCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
chargeCapControlEnabled: Boolean,
estimateEnabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onEstimateEnabledChange: (Boolean) -> Unit = {},
onResetEstimate: () -> Unit = {},
) {
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
val showLiveControls = device.isLive && device.profileId != null
if (chargeCap == null && !showLiveControls) return
val reactions = device.reactions
var showResetConfirm by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
// Time remaining & battery health lead — the headline feature. Charging-side settings
// (Apple's charge limit, the charged notification) follow below the divider.
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.Schedule,
title = stringResource(R.string.device_battery_estimate_toggle_label),
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
checked = estimateEnabled,
onCheckedChange = onEstimateEnabledChange,
)
SettingsBaseItem(
icon = Icons.TwoTone.RestartAlt,
title = stringResource(R.string.device_battery_estimate_reset_action),
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
onClick = { showResetConfirm = true },
)
// Same divider style the sibling device-settings cards use (see ReactionsCard).
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (chargeCap != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = chargeCap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = chargeCapControlEnabled,
)
}
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.NotificationsActive,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
}
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showResetConfirm) {
AlertDialog(
onDismissRequest = { showResetConfirm = false },
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
showResetConfirm = false
onResetEstimate()
},
) {
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
}
},
dismissButton = {
TextButton(onClick = { showResetConfirm = false }) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
}
@Preview2
@Composable
private fun BatteryCardEnabledPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = true,
chargeCapControlEnabled = true,
estimateEnabled = true,
)
}
@Preview2
@Composable
private fun BatteryCardDisabledPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = false,
chargeCapControlEnabled = false,
estimateEnabled = false,
)
}
@@ -29,10 +29,10 @@ internal fun ControlsCard(
val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength
val showPressControlsNav = features.hasStemConfig ||
features.hasPressSpeed ||
features.hasPressHoldDuration ||
features.hasEndCallMuteMic
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
if (showPressControlsNav) {
@@ -18,6 +18,11 @@ 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.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
internal fun DeviceInfoBottomSheet(
@@ -28,57 +33,110 @@ internal fun DeviceInfoBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
Text(
text = stringResource(R.string.device_settings_info_details_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
items.forEach { item ->
when (item) {
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = item.start.label,
value = item.start.value,
modifier = Modifier.weight(1f),
)
InfoRow(
label = item.end.label,
value = item.end.value,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
DeviceInfoDetailsContent(items)
}
}
@Composable
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
Text(
text = stringResource(R.string.device_settings_info_details_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
items.forEach { item ->
when (item) {
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = item.start.label,
value = item.start.value,
modifier = Modifier.weight(1f),
)
InfoRow(
label = item.end.label,
value = item.end.value,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
}
}
}
}
private val previewDateFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneOffset.UTC)
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Device Details",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
InfoRow(label = "Manufacturer", value = "Apple Inc.")
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
}
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = pairedAt,
rightEarbudFirstPaired = pairedAt,
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewMismatchedPairing() = PreviewWrapper {
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
firmwareVersionPending = "82.1000000075000000.7000",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = Instant.parse("2024-02-14T12:00:00Z"),
rightEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewOneSidedPairing() = PreviewWrapper {
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
leftEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
buildNumber = "8454624",
marketingVersion = "8454624",
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
@@ -0,0 +1,128 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
@Composable
internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
manufacturer = stringResource(R.string.device_settings_info_manufacturer_label),
hardware = stringResource(R.string.device_settings_info_hardware_label),
serial = stringResource(R.string.device_settings_info_serial_label),
firmware = stringResource(R.string.device_settings_info_firmware_label),
firmwarePending = stringResource(R.string.device_settings_info_firmware_pending_label),
build = stringResource(R.string.device_settings_info_build_label),
leftSerial = stringResource(R.string.device_settings_info_left_serial_label),
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
)
internal data class DeviceInfoDetailLabels(
val manufacturer: String,
val hardware: String,
val serial: String,
val firmware: String,
val firmwarePending: String,
val build: String,
val leftSerial: String,
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
val batteryHealth: String,
val leftBatteryHealth: String,
val rightBatteryHealth: String,
)
/**
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
* stays discoverable while the estimator is still accumulating listening sessions.
*/
internal data class BatteryHealthTexts(
val left: String? = null,
val right: String? = null,
val headset: String? = null,
val pending: String? = null,
)
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
if (health == null) return emptyList()
return buildList {
when {
health.left != null && health.right != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
)
)
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
}
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
if (isEmpty() && health.pending != null) {
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
}
}
}
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
batteryHealth: BatteryHealthTexts? = null,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
// Battery health is derived locally, so it's available (and shows the info button) even for
// BLE-only devices that never produce an AAP device-info response.
if (info == null) return healthItems(batteryHealth, labels)
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
}
info.hardwareVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.hardware, it))
}
info.serialNumber.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.serial, it))
}
info.firmwareVersion.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmware, it))
}
info.firmwareVersionPending?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmwarePending, it))
}
info.marketingVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.build, it))
}
val leftSerial = info.leftEarbudSerial?.takeIf { it.isNotBlank() }
val rightSerial = info.rightEarbudSerial?.takeIf { it.isNotBlank() }
when {
leftSerial != null && rightSerial != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftSerial, leftSerial),
end = DeviceDetailItem.Single(labels.rightSerial, rightSerial),
)
)
leftSerial != null -> add(DeviceDetailItem.Single(labels.leftSerial, leftSerial))
rightSerial != null -> add(DeviceDetailItem.Single(labels.rightSerial, rightSerial))
}
val leftBonded = info.leftEarbudFirstPaired?.let(formatDate)
val rightBonded = info.rightEarbudFirstPaired?.let(formatDate)
when {
leftBonded != null && rightBonded != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBonded, leftBonded),
end = DeviceDetailItem.Single(labels.rightBonded, rightBonded),
)
)
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
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),
type = InfoBoxType.INFO,
action = {
TextButton(onClick = onPressControlsClick) {
TextButton(
onClick = onPressControlsClick,
enabled = enabled,
) {
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.RecordVoiceOver
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -18,6 +20,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -29,39 +32,45 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ReactionsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -73,6 +82,16 @@ internal fun ReactionsCard(
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
if (reactions.autoPlay) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_start_music_on_wear_label),
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
checked = reactions.startMusicOnWear,
onCheckedChange = onStartMusicOnWearChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
@@ -117,20 +136,49 @@ internal fun ReactionsCard(
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
if (showConversationAwareness) {
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
checked = convAwareness?.enabled == true,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
// speaking frames otherwise), so only surface it once CA is actually enabled.
if (convAwareness?.enabled == true) {
SettingsBaseItem(
icon = Icons.TwoTone.RecordVoiceOver,
title = stringResource(R.string.settings_conversation_action_label),
subtitle = stringResource(reactions.conversationAction.labelRes),
onClick = { showConversationActionDialog = true },
enabled = enabled,
requiresUpgrade = !isPro,
)
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
var sliderValue by remember(reactions.conversationVolumeReduction) {
mutableIntStateOf(reactions.conversationVolumeReduction)
}
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
title = stringResource(R.string.settings_conversation_volume_reduction_label),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onConversationVolumeReductionChange(sliderValue) },
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
}
}
if (features.hasSleepDetection) {
if (showConversationAwareness) ReactionsDivider()
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
@@ -140,6 +188,7 @@ internal fun ReactionsCard(
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
requiresUpgrade = !isPro,
)
if (sleepDet.enabled) {
SettingsInfoBox(
@@ -197,23 +246,24 @@ internal fun ReactionsCard(
requiresUpgrade = !isPro,
)
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_warning_manual_mode),
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = onFixMonitorMode) {
Text(stringResource(R.string.general_fix_it_action))
}
},
)
} else if (anyPopupEnabled) {
if (anyPopupEnabled) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_info_not_in_app),
)
}
}
if (showConversationActionDialog) {
ConversationActionDialog(
current = reactions.conversationAction,
onSelect = {
onConversationActionChange(it)
showConversationActionDialog = false
},
onDismiss = { showConversationActionDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -245,7 +295,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -258,6 +307,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -81,7 +81,7 @@ internal fun SoundCard(
)
}
}
if (features.hasMicrophoneMode) {
if (features.hasMicrophoneMode && device.microphoneMode != null) {
if (isPro) {
val micMode = device.microphoneMode
?: 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 = {},
)
}
@@ -59,14 +59,33 @@ import eu.darken.capod.main.ui.overview.cards.DualPodsCard
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.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
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
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
@@ -155,10 +174,14 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
onDeviceSettings = { device ->
if (currentState.showReactionsHint) vm.dismissReactionsHint()
vm.goToDeviceSettings(device)
},
onEditProfile = { device -> vm.goToEditProfile(device) },
onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) }
@@ -174,6 +197,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -286,18 +310,31 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users)
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(
items = state.visibleProfiledDevices,
key = { _, device -> requireNotNull(device.profileId) },
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
) { index, device ->
val isCollapsed = !state.isExpanded(device, index)
val isToggleable = state.isToggleable(device, index)
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
isCollapsed = isCollapsed,
batteryEstimate = state.estimateFor(device),
onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) }
} else null,
@@ -319,32 +356,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
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
}
// 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) {
if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") {
UnmatchedDevicesCard(
count = state.unmatchedDevices.size,
count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched,
)
}
if (state.showUnmatchedDevices) {
items(
items = state.unmatchedDevices,
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
) { device ->
itemsIndexed(
items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device ->
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onUpgrade = onUpgrade,
@@ -365,6 +409,7 @@ private fun PodDeviceCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit,
@@ -375,6 +420,7 @@ private fun PodDeviceCard(
device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -384,6 +430,7 @@ private fun PodDeviceCard(
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -402,11 +449,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.dualPodFullyLoaded(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -414,6 +461,17 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
showReactionsHint = true,
userExpandedIds = setOf("preview-single"),
batteryEstimates = mapOf(
"preview-dual-full" to BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
),
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -432,7 +490,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(),
@@ -455,7 +513,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = MockPodDataProvider.gplayInfo(),
@@ -478,7 +536,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = false,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
@@ -4,8 +4,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -21,7 +21,12 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -32,12 +37,16 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine as combineFlows
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import javax.inject.Inject
@@ -48,11 +57,12 @@ class OverviewViewModel @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -75,29 +85,90 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $permissions" }
private data class OverviewUiSettings(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
/**
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
*/
private val troubleshootSuggestion = combineFlows(
profilesRepo.profiles,
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
deviceMonitor.devices,
permissionTool.missingScanPermissions,
) { profiles, connectedDevices, devices, missingScanPermissions ->
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
val anyLivePod = devices.any { it.isLive }
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
}
.distinctUntilChanged()
.flatMapLatest { conditionMet ->
if (conditionMet) flow {
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
emit(true)
} else flowOf(false)
}
.onStart { emit(false) }
.distinctUntilChanged()
private val overviewUiSettings = combineFlows(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimates = batteryEstimates,
)
}
init {
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
// toggle so the section reappears collapsed if the user later disables the setting.
launch {
generalSettings.hideUnmatchedDevices.flow
.filter { it }
.collect { showUnmatchedDevices.value = false }
}
}
val workerAutolaunch = combine(
permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode,
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
// would silently delay autolaunch until the first connected-device emission.
bluetoothManager.connectedDevices
.onStart { emit(emptyList()) }
.map { it.isNotEmpty() }
.distinctUntilChanged(),
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
.distinctUntilChanged()
.onEach { (missing, mode, anyConnected) ->
if (missing.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $missing" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
val shouldStart = when (mode) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
try {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
} catch (e: SecurityException) {
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
false
}
}
MonitorMode.AUTOMATIC -> anyConnected
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
log(TAG) { "Starting monitor" }
log(TAG) { "Starting monitor (mode=$mode)" }
monitorControl.startMonitor()
}
}
@@ -124,13 +195,15 @@ class OverviewViewModel @Inject constructor(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
Bugs.isDebug,
bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
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)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -139,12 +212,16 @@ class OverviewViewModel @Inject constructor(
now = timeSource.now(),
permissions = permissions,
devices = devices,
isDebugMode = isDebugMode,
isDebug = isDebug,
isBluetoothEnabled = isBluetoothEnabled,
profiles = profiles,
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -154,15 +231,25 @@ class OverviewViewModel @Inject constructor(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<PodDevice>,
val isDebugMode: Boolean,
val isDebug: Boolean,
val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
/**
* Time-remaining estimate to show for [device], or null when the device has the estimate
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
*/
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
/** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy {
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
@@ -170,7 +257,7 @@ class OverviewViewModel @Inject constructor(
val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) }
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
}
@@ -180,6 +267,11 @@ class OverviewViewModel @Inject constructor(
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
val visibleUnmatchedDevices: List<PodDevice>
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
val bluetoothIconState: BluetoothIconState
get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN
@@ -218,6 +310,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index)
}
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager)
@@ -252,6 +349,13 @@ class OverviewViewModel @Inject constructor(
}
}
fun dismissReactionsHint() {
log(TAG, INFO) { "dismissReactionsHint()" }
launch {
generalSettings.reactionsHintDismissed.value(true)
}
}
fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission)
@@ -271,13 +375,14 @@ class OverviewViewModel @Inject constructor(
companion object {
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 {
device.isSystemConnected -> 0
device.isLive -> 1
else -> 2
}
/**
* How long the "connected but no live data" condition must hold before the troubleshooter
* hint appears, so it doesn't flash during the gap between an audio connection and the first
* BLE broadcast.
*/
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
}
}
@@ -24,14 +24,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.GridView
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -52,6 +51,7 @@ import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
@@ -60,14 +60,15 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Instant
@Composable
@@ -77,6 +78,7 @@ fun DualPodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -167,7 +169,7 @@ fun DualPodsCard(
}
if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
OutlinedIconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -175,15 +177,15 @@ fun DualPodsCard(
)
}
}
if (device.profileId != null && device.address == null && onEditProfile != null) {
IconButton(onClick = onEditProfile) {
Icon(
imageVector = Icons.TwoTone.Warning,
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
if (!isCollapsed
&& device.profileId != null
&& !device.hasSelectedPairedDevice
&& onEditProfile != null
) {
Spacer(modifier = Modifier.height(12.dp))
MissingPairedDeviceBanner(onClick = onEditProfile)
}
if (isCollapsed) {
@@ -193,6 +195,7 @@ fun DualPodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -205,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
@@ -226,24 +231,30 @@ private fun ColumnScope.DualPodsCardExpanded(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(),
isCharging = device.isLeftPodCharging ?: false,
batteryPercent = device.batteryLeft,
chargingState = device.leftPodChargingState
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isLeftInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(),
isCharging = device.isRightPodCharging ?: false,
batteryPercent = device.batteryRight,
chargingState = device.rightPodChargingState
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isRightInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
}
@@ -292,12 +303,14 @@ private fun ColumnScope.DualPodsCardExpanded(
private fun PodGauge(
iconRes: Int,
batteryPercent: Float,
isCharging: Boolean,
chargingState: AapPodState.ChargingState?,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
modifier: Modifier = Modifier,
timeRemaining: String? = null,
untilCharged: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -355,29 +368,42 @@ private fun PodGauge(
Spacer(modifier = Modifier.height(6.dp))
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage, with the time-remaining estimate inline next to it
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.alignByBaseline(),
)
if (timeRemaining != null) {
Text(
text = "· $timeRemaining",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline(),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Status chips
StatusChipRow(
isCharging = isCharging,
chargingState = chargingState,
isInEar = isInEar,
showEarDetection = showEarDetection,
isMicrophone = isMicrophone,
showMicrophone = showMicrophone,
chargingLabel = stringResource(R.string.pods_charging_label),
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label),
chargingDetail = untilCharged,
)
}
}
@@ -408,7 +434,7 @@ private fun CaseRow(
)
BatteryCapsule(
percent = device.batteryCase.toBatteryFloat(),
percent = device.batteryCase,
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -420,11 +446,17 @@ private fun CaseRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isCaseCharging == true) {
StatusChip(
when (val caseState = device.caseChargingState
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
else -> Unit
}
val lidState = device.caseLidState
@@ -446,10 +478,49 @@ private fun CaseRow(
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
// enough live samples.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
),
onDeviceSettings = {},
)
}
@@ -490,7 +561,7 @@ private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
@Composable
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredMixed(),
device = MockPodDataProvider.dualPodMissingPairedDevice(),
showDebug = false,
now = SystemTimeSource.now(),
onEditProfile = {},
@@ -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()
}
@@ -24,13 +24,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -50,6 +49,7 @@ import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.common.SystemTimeSource
@@ -57,9 +57,14 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@@ -70,6 +75,7 @@ fun SinglePodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -149,7 +155,7 @@ fun SinglePodsCard(
}
if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
OutlinedIconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -157,15 +163,15 @@ fun SinglePodsCard(
)
}
}
if (device.profileId != null && device.address == null && onEditProfile != null) {
IconButton(onClick = onEditProfile) {
Icon(
imageVector = Icons.TwoTone.Warning,
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
if (!isCollapsed
&& device.profileId != null
&& !device.hasSelectedPairedDevice
&& onEditProfile != null
) {
Spacer(modifier = Modifier.height(12.dp))
MissingPairedDeviceBanner(onClick = onEditProfile)
}
if (isCollapsed) {
@@ -175,6 +181,7 @@ fun SinglePodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -187,21 +194,23 @@ private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
val percent = device.batteryHeadset
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -236,7 +245,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
// Progress ring
if (clamped != null) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(88.dp),
@@ -247,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadset),
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage + time remaining, stacked inside the ring
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
softWrap = false,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -267,11 +288,25 @@ private fun ColumnScope.SinglePodsCardExpanded(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isHeadsetBeingCharged == true) {
StatusChip(
when (val headsetState = device.headsetChargingState
?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> {
// Time-until-charged lives in the charging chip; the in-ring estimate
// always means runtime, so the two can't be confused.
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
?.let { formatBatteryDurationShort(context, it) }
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = untilCharged
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
?: stringResource(R.string.pods_charging_label),
)
}
else -> Unit
}
if (device.isBeingWorn == true) {
StatusChip(
@@ -323,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
batteryEstimate = BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
@@ -359,7 +409,7 @@ private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
@Composable
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
device = MockPodDataProvider.singlePodMissingPairedDevice(),
showDebug = false,
now = SystemTimeSource.now(),
onEditProfile = {},
@@ -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 eu.darken.capod.R
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.isKnownBattery
@Composable
fun CompactBatterySummary(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val hasAnyBattery = device.batteryLeft != null
|| device.batteryRight != null
|| device.batteryHeadset != null
|| device.batteryCase != null
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|| isKnownBattery(device.batteryRight)
|| isKnownBattery(device.batteryHeadset)
|| isKnownBattery(device.batteryCase)
Surface(
modifier = modifier
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
percent = device.batteryRight,
)
if (device.hasCase && device.batteryCase != null) {
if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
iconRes = null,
percent = device.batteryHeadset,
)
if (device.hasCase && device.batteryCase != null) {
if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
@Composable
private fun MiniPodRing(
iconRes: Int?,
percent: Float?,
percent: Float,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f)
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -155,7 +157,7 @@ private fun MiniPodRing(
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
if (clamped != null) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(28.dp),
@@ -177,7 +179,7 @@ private fun MiniPodRing(
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall,
color = if (percent != null) {
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
)
Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule(
percent = device.batteryCase ?: -1f,
percent = device.batteryCase,
modifier = Modifier
.width(36.dp)
.height(6.dp),
@@ -0,0 +1,63 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
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.semantics.semantics
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 MissingPairedDeviceBanner(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.padding(end = 10.dp)
.size(20.dp),
)
Text(
text = stringResource(R.string.overview_card_missing_paired_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
modifier = Modifier.weight(1f),
)
}
}
}
@Preview2
@Composable
private fun MissingPairedDeviceBannerPreview() = PreviewWrapper {
MissingPairedDeviceBanner(onClick = {})
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.aap.AapPodState
@Composable
fun StatusChip(
@@ -60,26 +61,35 @@ fun StatusChip(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusChipRow(
isCharging: Boolean,
chargingState: AapPodState.ChargingState?,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
chargingLabel: String,
chargingOptimizedLabel: String,
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
chargingDetail: String? = null,
) {
FlowRow(
modifier = modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isCharging) {
StatusChip(
when (chargingState) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
label = chargingOptimizedLabel,
)
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
// estimate line under the percentage always means runtime, so the two can't be confused.
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
)
else -> Unit
}
if (showMicrophone && isMicrophone) {
StatusChip(
@@ -106,12 +116,30 @@ private fun StatusChipChargingPreview() = PreviewWrapper {
@Composable
private fun StatusChipRowAllPreview() = PreviewWrapper {
StatusChipRow(
isCharging = true,
chargingState = AapPodState.ChargingState.CHARGING,
isInEar = true,
showEarDetection = true,
isMicrophone = true,
showMicrophone = true,
chargingLabel = "Charging",
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
chargingDetail = "25m",
)
}
@Preview2
@Composable
private fun StatusChipRowOptimizedPreview() = PreviewWrapper {
StatusChipRow(
chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED,
isInEar = false,
showEarDetection = true,
isMicrophone = true,
showMicrophone = true,
chargingLabel = "Charging",
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
)
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
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(
state = currentState,
snackbarHostState = snackbarHostState,
@@ -100,6 +100,13 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
)
}
item {
SettingsBaseItem(
title = "apple-wireshark",
subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.",
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
)
}
item {
SettingsBaseItem(
title = "crowdin.com",
@@ -110,13 +117,6 @@ fun AcknowledgementsScreen(
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
}
item {
SettingsBaseItem(
title = "Glide",
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
)
}
item {
SettingsBaseItem(
title = "Material Design Icons",
@@ -145,6 +145,13 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
)
}
item {
SettingsBaseItem(
title = "apple-wireshark",
subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)",
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
)
}
item {
SettingsBaseItem(
title = "Kotlin",
@@ -166,13 +173,6 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
)
}
item {
SettingsBaseItem(
title = "Android",
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
)
}
}
}
}
@@ -1,51 +1,37 @@
package eu.darken.capod.main.ui.settings.general
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.AccountTree
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.Contrast
import androidx.compose.material.icons.twotone.DarkMode
import androidx.compose.material.icons.twotone.DisabledVisible
import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.SettingsBluetooth
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.bluetooth.ScannerMode
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
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.ThemeState
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
@Composable
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
@@ -69,14 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
GeneralSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -89,22 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onScannerModeSelected: (ScannerMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier,
) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showScannerModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -127,25 +106,6 @@ fun GeneralSettingsScreen(
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_monitor_mode_label),
subtitle = stringResource(state.monitorMode.labelRes),
icon = Icons.TwoTone.DisabledVisible,
onClick = { showMonitorModeDialog = true },
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_scanner_mode_label),
subtitle = stringResource(state.scannerMode.labelRes),
icon = Icons.TwoTone.SettingsBluetooth,
onClick = { showScannerModeDialog = true },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
}
@@ -242,10 +202,17 @@ fun GeneralSettingsScreen(
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_label),
subtitle = stringResource(R.string.settings_debug_description),
icon = Icons.TwoTone.BugReport,
onClick = onDebugSettings,
title = stringResource(R.string.settings_overview_hide_unmatched_label),
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
icon = Icons.TwoTone.VisibilityOff,
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
trailingContent = {
Switch(
checked = state.hideUnmatchedDevices,
onCheckedChange = onHideUnmatchedDevicesChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
@@ -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) {
ThemeColorSelectorDialog(
selectedColor = state.themeState.color,
@@ -341,13 +280,12 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC,
scannerMode = ScannerMode.BALANCED,
showConnectedNotification = true,
keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(),
)
@@ -357,14 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = true),
onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -374,61 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = false),
onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
)
}
@Composable
private fun <T> ListPreferenceDialog(
title: String,
entries: List<T>,
selectedEntry: T,
onEntrySelected: (T) -> Unit,
entryLabel: @Composable (T) -> String,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = {
Column(Modifier.selectableGroup()) {
entries.forEach { entry ->
val isSelected = entry == selectedEntry
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onEntrySelected(entry) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = entryLabel(entry),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -1,7 +1,6 @@
package eu.darken.capod.main.ui.settings.general
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.debug.logging.Logging.Priority.INFO
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.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.themeState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -31,13 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
data class State(
val isPro: Boolean,
val monitorMode: MonitorMode,
val scannerMode: ScannerMode,
val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState,
)
@@ -45,13 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine(
combine(
generalSettings.monitorMode.flow,
generalSettings.scannerMode.flow,
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, scannerMode, showNotif, keepNotif ->
) { showNotif, keepNotif ->
@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(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -63,30 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
},
generalSettings.themeState,
isPro,
) { general, compat, themeState, isPro ->
generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State(
isPro = isPro,
monitorMode = general[0] as MonitorMode,
scannerMode = general[1] as ScannerMode,
showConnectedNotification = general[2] as Boolean,
keepNotificationAfterDisconnect = general[3] as Boolean,
showConnectedNotification = general[0] as Boolean,
keepNotificationAfterDisconnect = general[1] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState,
)
}.asLiveState()
fun setMonitorMode(mode: MonitorMode) {
log(TAG, INFO) { "setMonitorMode($mode)" }
generalSettings.monitorMode.valueBlocking = mode
}
fun setScannerMode(mode: ScannerMode) {
log(TAG, INFO) { "setScannerMode($mode)" }
generalSettings.scannerMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -112,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
}
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) {
@@ -144,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade)
}
fun goToDebugSettings() {
log(TAG, INFO) { "goToDebugSettings()" }
navTo(Nav.Settings.Debug)
}
companion object {
private val TAG = logTag("Settings", "General", "VM")
}
@@ -1,26 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
}
}
@@ -1,136 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.DataArray
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
@Composable
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
DebugSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
)
}
}
@Composable
fun DebugSettingsScreen(
state: DebugSettingsViewModel.State,
onNavigateUp: () -> Unit,
onDebugModeChanged: (Boolean) -> Unit,
onShowFakeDataChanged: (Boolean) -> Unit,
onShowUnfilteredChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_debug_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_mode_label),
subtitle = stringResource(R.string.settings_debug_mode_description),
icon = Icons.TwoTone.BugReport,
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
trailingContent = {
Switch(
checked = state.isDebugModeEnabled,
onCheckedChange = onDebugModeChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_fake_data_label),
subtitle = stringResource(R.string.settings_fake_data_description),
icon = Icons.TwoTone.DataArray,
onClick = { onShowFakeDataChanged(!state.showFakeData) },
trailingContent = {
Switch(
checked = state.showFakeData,
onCheckedChange = onShowFakeDataChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_blescanner_unfiltered_label),
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
icon = Icons.TwoTone.DevicesOther,
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
trailingContent = {
Switch(
checked = state.showUnfiltered,
onCheckedChange = onShowUnfilteredChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
}
}
}
@Preview2
@Composable
private fun DebugSettingsScreenPreview() = PreviewWrapper {
DebugSettingsScreen(
state = DebugSettingsViewModel.State(
isDebugModeEnabled = true,
showFakeData = false,
showUnfiltered = false,
),
onNavigateUp = {},
onDebugModeChanged = {},
onShowFakeDataChanged = {},
onShowUnfilteredChanged = {},
)
}
@@ -1,57 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val debugSettings: DebugSettings,
) : ViewModel4(dispatcherProvider) {
data class State(
val isDebugModeEnabled: Boolean,
val showFakeData: Boolean,
val showUnfiltered: Boolean,
)
val state = combine(
debugSettings.isDebugModeEnabled.flow,
debugSettings.showFakeData.flow,
debugSettings.showUnfiltered.flow,
) { debugMode, fakeData, unfiltered ->
State(
isDebugModeEnabled = debugMode,
showFakeData = fakeData,
showUnfiltered = unfiltered,
)
}.asLiveState()
fun setDebugModeEnabled(enabled: Boolean) {
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
debugSettings.isDebugModeEnabled.valueBlocking = enabled
}
fun setShowFakeData(enabled: Boolean) {
log(TAG, INFO) { "setShowFakeData($enabled)" }
debugSettings.showFakeData.valueBlocking = enabled
}
fun setShowUnfiltered(enabled: Boolean) {
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
debugSettings.showUnfiltered.valueBlocking = enabled
}
companion object {
private val TAG = logTag("Settings", "Debug", "VM")
}
}
@@ -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.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId
import androidx.glance.LocalSize
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.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class AncGlanceWidget : GlanceAppWidget() {
@@ -43,17 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: AncWidgetEntryPoint
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
@@ -73,26 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
}
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 heightDp = LocalSize.current.height
val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val theme = WidgetTheme.fromBundle(
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
)
// Glance keeps the content session alive and does not restart provideGlance()
// for every update(). Observe a widget-key-deduped device flow so visible state
// changes update active sessions without recomposing on every BLE advertisement.
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
.getOrDefault(false)
val initialDevice = remember(config.profileId) {
config.profileId?.let { pid ->
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 heightCells = getCellsForSize(heightDp.value.toInt())
@@ -108,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
AncWidgetRenderStateMapper.map(
context = context,
device = device,
theme = theme,
theme = config.theme,
isPro = isPro,
hasConfiguredProfile = profileId != null,
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
} else {
AncWidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -158,7 +171,7 @@ class AncGlanceWidget : GlanceAppWidget() {
private fun getCellsForSize(size: Int): Int {
var n = 2
while (70 * n - 30 < size) {
while (70 * n - 30 <= size) {
++n
}
return n - 1
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.updateAll
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
fun aapConnectionManager(): AapConnectionManager
fun widgetSettings(): WidgetSettings
fun deviceMonitor(): DeviceMonitor
fun widgetManager(): WidgetManager
}
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
if (profileId == null) {
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
return
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
if (device == null) {
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
return
}
val address = device.address
if (address == null) {
log(TAG, ERROR) { "onAction: device has no address" }
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
return
}
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
}
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
}
companion object {
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Context
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.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
)
!device.isAapConnected -> AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
)
!device.isAapConnected -> {
val secondaryRes = if (device.ble != null) {
R.string.anc_widget_aap_not_connected_nearby_description
} else {
R.string.anc_widget_aap_not_connected_not_nearby_description
}
AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = profileLabel ?: device.getLabel(context),
secondaryText = context.getString(secondaryRes),
)
}
!device.isAapReady -> AncWidgetRenderState.Message(
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.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId
import androidx.glance.LocalSize
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.isPro
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 kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -38,31 +45,32 @@ class BatteryGlanceWidget : GlanceAppWidget() {
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
fun batteryEstimator(): BatteryEstimator
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: WidgetEntryPoint
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
GlanceWidgetContent(
state = WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
),
context = context,
@@ -72,45 +80,65 @@ class BatteryGlanceWidget : GlanceAppWidget() {
}
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 layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val theme = WidgetTheme.fromBundle(
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
)
// Glance keeps the content session alive and does not restart provideGlance()
// for every update(). Observe a widget-key-deduped device flow so visible state
// changes update active sessions without recomposing on every BLE advertisement.
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
.getOrDefault(false)
val initialDevice = remember(config.profileId) {
config.profileId?.let { pid ->
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()
}
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
WidgetRenderStateMapper.map(
context = context,
device = device,
theme = theme,
theme = config.theme,
isPro = isPro,
hasConfiguredProfile = profileId != null,
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
isWide = isWide,
layout = layout,
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
} else {
WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
)
}
@@ -124,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
)
).withLocalizedPreviewEstimates(context)
provideContent {
GlanceWidgetContent(state = previewState, context = context)
@@ -137,7 +165,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
*/
private fun getCellsForSize(size: Int): Int {
var n = 2
while (70 * n - 30 < size) {
while (70 * n - 30 <= size) {
++n
}
return n - 1
@@ -25,12 +25,13 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import kotlin.math.roundToInt
@Composable
@@ -87,86 +88,51 @@ private fun DualPodPreview(
val iconColor = Color(state.resolvedIconColor)
val iconTint = ColorFilter.tint(iconColor)
if (state.isWide) {
// Wide layout: left | case | right in a horizontal row
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Left pod
PodItemRow(
icon = state.leftIcon,
percent = state.leftPercent,
charging = state.leftCharging,
inEar = state.leftInEar,
when (state.layout) {
BatteryLayout.WIDE -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Case
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Right pod
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
}
} else {
// Compact layout: vertical stack
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(
icon = state.leftIcon,
percent = state.leftPercent,
charging = state.leftCharging,
inEar = state.leftInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
)
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
}
}
BatteryLayout.TINY_COLUMN -> {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
}
}
}
}
@@ -180,45 +146,61 @@ private fun SinglePodPreview(
val textColor = Color(state.resolvedTextColor)
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(state.batteryIcon),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (state.worn) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
when (state.layout) {
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(state.batteryIcon),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (state.worn) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
}
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
BatteryLayout.TINY_COLUMN -> {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
}
}
}
}
@@ -229,17 +211,25 @@ private fun MessagePreview(
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text(
text = state.primaryText,
fontSize = 12.sp,
fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
if (state.secondaryText != null) {
if (!isCompact && state.secondaryText != null) {
Text(
text = state.secondaryText,
fontSize = 12.sp,
@@ -260,11 +250,17 @@ private fun LoadingPreview(
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text(
text = "",
fontSize = 12.sp,
fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
@@ -277,13 +273,15 @@ private fun LoadingPreview(
private fun WidgetContainer(
bgColor: Color,
modifier: Modifier = Modifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -300,41 +298,84 @@ private fun PodItemRow(
textColor: Color,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (estimateBelow && estimate != null) {
Text(
text = estimate,
fontSize = 12.sp,
color = textColor,
maxLines = 1,
)
}
}
}
@Composable
private fun TinyPodItem(
icon: Int,
percent: Float,
textColor: Color,
iconTint: ColorFilter,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
modifier = Modifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
maxLines = 1,
modifier = Modifier.padding(start = 2.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,
)
}
}
}
@@ -357,18 +398,37 @@ private fun DeviceLabel(
}
}
private fun formatPercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
private fun formatPercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@Preview2
@Composable
private fun PreviewDualTiny11() = PreviewWrapper {
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(40.dp),
)
}
@Preview2
@Composable
private fun PreviewDualCompact() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
private fun PreviewDualTinyTall() = PreviewWrapper {
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(width = 40.dp, height = 110.dp),
)
}
@Preview2
@Composable
private fun PreviewDualNarrow() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
}
@Preview2
@Composable
private fun PreviewDualWide() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
}
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
@@ -26,7 +27,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import eu.darken.capod.R
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
@Composable
@@ -53,31 +54,47 @@ private fun GlanceDualPod(
state: WidgetRenderState.DualPod,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
if (state.isWide) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.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)
when (state.layout) {
BatteryLayout.WIDE -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
} else {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
}
BatteryLayout.TINY_COLUMN -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.leftIcon, state.leftPercent, textStyle, iconTint)
GlanceTinyPodItem(state.rightIcon, state.rightPercent, textStyle, iconTint)
GlanceTinyPodItem(state.caseIcon, state.casePercent, textStyle, iconTint)
}
}
}
}
@@ -87,47 +104,61 @@ private fun GlanceSinglePod(
state: WidgetRenderState.SinglePod,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(state.batteryIcon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
when (state.layout) {
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(state.batteryIcon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (state.worn) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
if (state.worn) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
BatteryLayout.TINY_COLUMN -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.batteryIcon, state.percent, textStyle, iconTint)
}
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
}
@@ -136,20 +167,29 @@ private fun GlanceMessage(
state: WidgetRenderState.Message,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
val fontSize = if (isCompact) 10.sp else 12.sp
val hPad = if (isCompact) 4.dp else 16.dp
val vPad = if (isCompact) 0.dp else 4.dp
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = hPad,
verticalPadding = vPad,
) {
Text(
text = state.primaryText,
style = textStyle,
style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.fillMaxWidth(),
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
)
if (state.secondaryText != null) {
if (!isCompact && state.secondaryText != null) {
Text(
text = state.secondaryText,
style = TextStyle(
@@ -169,12 +209,18 @@ private fun GlanceLoading(
state: WidgetRenderState.Loading,
clickModifier: GlanceModifier,
) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text(
text = "",
style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
@@ -187,13 +233,15 @@ private fun GlanceLoading(
private fun GlanceWidgetRoot(
bgColor: Int,
clickModifier: GlanceModifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit,
) {
Column(
modifier = clickModifier
.fillMaxSize()
.background(fixedColor(bgColor))
.padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -210,6 +258,60 @@ private fun GlancePodItem(
textStyle: TextStyle,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: GlanceModifier = GlanceModifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (estimateBelow && estimate != null) {
Text(
text = estimate,
style = textStyle,
maxLines = 1,
)
}
}
}
@Composable
private fun GlanceTinyPodItem(
icon: Int,
percent: Float,
textStyle: TextStyle,
iconTint: ColorFilter,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
@@ -219,30 +321,15 @@ private fun GlancePodItem(
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
modifier = GlanceModifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
maxLines = 1,
modifier = GlanceModifier.padding(start = 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,
)
}
}
}
@@ -267,6 +354,7 @@ private fun GlanceDeviceLabel(
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
}
private fun formatGlancePercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@@ -0,0 +1,10 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.serialization.Serializable
@Serializable
data class WidgetConfig(
val profileId: ProfileId? = null,
val theme: WidgetTheme = WidgetTheme(),
)
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel)
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
}
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
}
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID" }
} else {
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
}
}
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
private val initialTheme: WidgetTheme = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
private val initialConfig: WidgetConfig = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
widgetSettings.getWidgetConfig(widgetId)
}
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
private val selectedProfile = MutableStateFlow(initialConfig.profileId)
private val currentTheme = MutableStateFlow(initialTheme)
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
private val currentTheme = MutableStateFlow(initialConfig.theme)
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
}
fun confirmSelection() {
val selectedProfile = selectedProfile.value
if (selectedProfile != null) {
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
return
}
// Save theme to AppWidgetOptions bundle
val theme = currentTheme.value
log(TAG, INFO) { "confirmSelection: saving theme=$theme" }
val options = appWidgetManager.getAppWidgetOptions(widgetId)
theme.toBundle(options)
appWidgetManager.updateAppWidgetOptions(widgetId, options)
val config = WidgetConfig(
profileId = selectedProfile.value,
theme = currentTheme.value,
)
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
widgetSettings.saveWidgetConfig(widgetId, config)
}
companion object {
@@ -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() }
@@ -0,0 +1,59 @@
package eu.darken.capod.main.ui.widget
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.aap.protocol.AapSetting
/**
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to
* gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
* doesn't cause a refresh on every BLE advertisement.
*/
internal data class WidgetDeviceKey(
val profileId: String?,
val profileLabel: String?,
val model: PodModel,
val batteryLeft: Float,
val batteryRight: Float,
val batteryCase: Float,
val batteryHeadset: Float,
val isLeftPodCharging: Boolean?,
val isRightPodCharging: Boolean?,
val isCaseCharging: 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 pendingAncMode: AapSetting.AncMode.Value?,
val visibleAncModes: List<AapSetting.AncMode.Value>,
)
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
profileId = profileId,
profileLabel = label,
model = model,
batteryLeft = batteryLeft,
batteryRight = batteryRight,
batteryCase = batteryCase,
batteryHeadset = batteryHeadset,
isLeftPodCharging = isLeftPodCharging,
isRightPodCharging = isRightPodCharging,
isCaseCharging = isCaseCharging,
isHeadsetBeingCharged = isHeadsetBeingCharged,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
ancMode = ancMode?.current,
pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes,
)
@@ -1,9 +1,16 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
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 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
@@ -16,9 +23,63 @@ class WidgetManager @Inject constructor(
) {
suspend fun refreshWidgets() {
log(TAG, VERBOSE) { "refreshWidgets()" }
BatteryGlanceWidget().updateAll(context)
AncGlanceWidget().updateAll(context)
val manager = GlanceAppWidgetManager(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 {
@@ -1,30 +1,49 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
enum class BatteryLayout {
TINY_COLUMN,
NARROW,
WIDE;
companion object {
fun forCells(widthCells: Int): BatteryLayout = when {
widthCells <= 1 -> TINY_COLUMN
widthCells >= 5 -> WIDE
else -> NARROW
}
}
}
sealed class WidgetRenderState {
abstract val theme: WidgetTheme
@get:ColorInt abstract val resolvedBgColor: Int
@get:ColorInt abstract val resolvedTextColor: Int
@get:ColorInt abstract val resolvedIconColor: Int
abstract val layout: BatteryLayout
data class DualPod(
override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
val isWide: Boolean,
override val layout: BatteryLayout,
val deviceLabel: String?,
@DrawableRes val leftIcon: Int,
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
val leftEstimate: String?,
@DrawableRes val rightIcon: Int,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
val rightEstimate: String?,
@DrawableRes val caseIcon: Int,
val casePercent: Float,
val caseCharging: Boolean,
@@ -35,12 +54,14 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val deviceLabel: String?,
@DrawableRes val headsetIcon: Int,
val percent: Float,
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
val estimate: String?,
) : WidgetRenderState()
data class Message(
@@ -48,6 +69,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val primaryText: String,
val secondaryText: String? = null,
) : WidgetRenderState()
@@ -57,6 +79,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
) : WidgetRenderState()
companion object {
@@ -65,25 +88,57 @@ sealed class WidgetRenderState {
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
isWide: Boolean = false,
layout: BatteryLayout = BatteryLayout.NARROW,
): DualPod = DualPod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
isWide = isWide,
layout = layout,
deviceLabel = "My AirPods Pro",
leftIcon = R.drawable.device_airpods_pro2_left,
leftPercent = 0.85f,
leftCharging = false,
leftInEar = true,
leftEstimate = "4h 55m",
rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f,
rightCharging = true,
rightInEar = false,
rightEstimate = "25m",
caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f,
caseCharging = false,
)
fun previewSinglePod(
theme: WidgetTheme = WidgetTheme.DEFAULT,
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
layout: BatteryLayout = BatteryLayout.NARROW,
): SinglePod = SinglePod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
layout = layout,
deviceLabel = "My AirPods Max",
headsetIcon = R.drawable.device_airpods_max,
percent = 0.72f,
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
estimate = "14h 30m",
)
}
}
/**
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
*/
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
leftEstimate = formatBatteryDurationShort(context, 295),
rightEstimate = formatBatteryDurationShort(context, 25),
)

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