Compare commits

..
59 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
217 changed files with 11048 additions and 793 deletions
+1 -1
View File
@@ -1 +1 @@
5.1.6-rc0 50106000
5.2.1-rc0 50201000
@@ -21,11 +21,14 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
@@ -52,6 +55,29 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
MockPodDataProvider.unknownMonitored(),
)
}
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
// the charging chip of whichever mock pod is charging.
val estimates = if (showAap) {
mapOf(
"preview-dual-aap" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single-aap" to BatteryEstimate(
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
),
)
} else {
mapOf(
"preview-dual-mixed" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
),
)
}
OverviewScreen(
state = OverviewViewModel.State(
now = MOCK_NOW,
@@ -65,6 +91,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -229,7 +256,7 @@ internal fun HomescreenWidgetContent() {
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
),
).withLocalizedPreviewEstimates(LocalContext.current),
)
}
}
+8 -8
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_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">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</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>
+7 -7
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="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
<string name="upgrade_badge_label">النسخة الاحترافية</string>
</resources>
+3 -3
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmantojat pareizo kontu?</string>
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmanto pareizo kontu?</string>
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūdzu, mēģiniet vēlreiz vēlāk vai pārstartējiet tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūgums mēģināt vēlreiz vēlāk vai pārsāknēt tālruni.\n\nKļūda: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play jūsu pirkuma informāciju. Notīriet Google Play kešatmiņu un pārstartējiet tālruni.\n\nKļūda %s</string>
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play pirkuma informāciju. Jāiztīra Google Play kešatmiņa un pārsāknē tālrunis.\n\nKļūda %s</string>
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
<string name="upgrade_badge_label">Pro</string>
</resources>
+14 -2
View File
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -36,6 +40,7 @@ open class App : Application() {
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -62,8 +67,15 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles()
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
combine(
deviceMonitor.devicesWithProfiles(),
batteryEstimator.estimates,
) { devices, estimates -> devices to estimates }
// Estimate display values can change while the device key is stable (e.g. a charge
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
.distinctUntilChangedBy { (devices, estimates) ->
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
}
.throttleLatest(1000)
.onEach {
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
@@ -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")
@@ -212,6 +212,37 @@ object MockPodDataProvider {
),
)
/**
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
*/
fun dualPodFullyLoaded(): PodDevice = PodDevice(
profileId = "preview-dual-full",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
pod = AapSetting.PrimaryPod.Pod.LEFT,
),
),
),
)
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
profileId = "preview-dual-reactions",
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
@@ -83,6 +83,8 @@ class GeneralSettings @Inject constructor(
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,
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
@@ -64,6 +65,7 @@ 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
@@ -175,8 +177,13 @@ fun DeviceSettingsScreenHost(
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
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() },
)
}
@@ -215,8 +222,13 @@ fun DeviceSettingsScreen(
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> 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
@@ -284,6 +296,23 @@ fun DeviceSettingsScreen(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
@@ -361,6 +390,25 @@ fun DeviceSettingsScreen(
}
}
// ── 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) {
@@ -429,18 +477,6 @@ fun DeviceSettingsScreen(
}
}
// ── Battery ──────────────────────────────────
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
enabled = enabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
)
}
}
// ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
@@ -22,6 +22,10 @@ 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
@@ -31,6 +35,7 @@ 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
@@ -54,6 +59,8 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val batteryEstimator: BatteryEstimator,
private val drainStore: BatteryDrainStore,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
@@ -121,6 +128,7 @@ class DeviceSettingsViewModel @Inject constructor(
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
nudgeCapabilityStore.availability,
drainStore.profiles,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info
@@ -133,9 +141,12 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val nudgeAvailability = args[7] as NudgeAvailability
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
@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 {
@@ -157,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()
@@ -182,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()
}
@@ -438,6 +468,24 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
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
@@ -454,6 +502,19 @@ class DeviceSettingsViewModel @Inject constructor(
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()" }
@@ -2,41 +2,176 @@ package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.NotificationsActive
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material.icons.twotone.Schedule
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
/**
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
* used by every other boolean setting.
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
* battery health lead, then a divider, then the charging-side settings:
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
* it starts over from the model's rated battery life.
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
* works for any live device (BLE or AAP), not just when the phone is the audio source.
*
* Each row is gated independently; the whole card hides when nothing applies.
*/
@Composable
internal fun BatteryCard(
device: PodDevice,
features: PodModel.Features,
enabled: Boolean,
isPro: Boolean,
chargeCapControlEnabled: Boolean,
estimateEnabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onEstimateEnabledChange: (Boolean) -> Unit = {},
onResetEstimate: () -> Unit = {},
) {
if (!features.hasDynamicEndOfCharge) return
val cap = device.dynamicEndOfCharge ?: return
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
val showLiveControls = device.isLive && device.profileId != null
if (chargeCap == null && !showLiveControls) return
val reactions = device.reactions
var showResetConfirm by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = cap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = enabled,
// Time remaining & battery health lead — the headline feature. Charging-side settings
// (Apple's charge limit, the charged notification) follow below the divider.
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.Schedule,
title = stringResource(R.string.device_battery_estimate_toggle_label),
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
checked = estimateEnabled,
onCheckedChange = onEstimateEnabledChange,
)
SettingsBaseItem(
icon = Icons.TwoTone.RestartAlt,
title = stringResource(R.string.device_battery_estimate_reset_action),
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
onClick = { showResetConfirm = true },
)
// Same divider style the sibling device-settings cards use (see ReactionsCard).
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (chargeCap != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = chargeCap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = chargeCapControlEnabled,
)
}
if (showLiveControls) {
SettingsSwitchItem(
icon = Icons.TwoTone.NotificationsActive,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
}
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showResetConfirm) {
AlertDialog(
onDismissRequest = { showResetConfirm = false },
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
showResetConfirm = false
onResetEstimate()
},
) {
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
}
},
dismissButton = {
TextButton(onClick = { showResetConfirm = false }) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
}
@@ -48,8 +183,10 @@ private fun BatteryCardEnabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = true,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = true,
chargeCapControlEnabled = true,
estimateEnabled = true,
)
}
@@ -60,7 +197,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = false,
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
isPro = false,
chargeCapControlEnabled = false,
estimateEnabled = false,
)
}
@@ -18,6 +18,9 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
)
internal data class DeviceInfoDetailLabels(
@@ -31,14 +34,52 @@ internal data class DeviceInfoDetailLabels(
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
val batteryHealth: String,
val leftBatteryHealth: String,
val rightBatteryHealth: String,
)
/**
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
* stays discoverable while the estimator is still accumulating listening sessions.
*/
internal data class BatteryHealthTexts(
val left: String? = null,
val right: String? = null,
val headset: String? = null,
val pending: String? = null,
)
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
if (health == null) return emptyList()
return buildList {
when {
health.left != null && health.right != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
)
)
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
}
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
if (isEmpty() && health.pending != null) {
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
}
}
}
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
batteryHealth: BatteryHealthTexts? = null,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
if (info == null) return emptyList()
// Battery health is derived locally, so it's available (and shows the info button) even for
// BLE-only devices that never produce an AAP device-info response.
if (info == null) return healthItems(batteryHealth, labels)
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
@@ -82,5 +123,6 @@ internal fun buildDeviceInfoDetailItems(
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
}
addAll(healthItems(batteryHealth, labels))
}
}
@@ -20,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
@@ -159,11 +160,15 @@ internal fun ReactionsCard(
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 = reactions.conversationVolumeReduction.toFloat(),
onValueChange = { onConversationVolumeReductionChange(it.toInt()) },
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,
@@ -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 = {},
)
}
@@ -61,9 +61,11 @@ import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
@@ -172,6 +174,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -194,6 +197,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -330,6 +334,7 @@ fun OverviewScreen(
showDebug = state.isDebug,
now = state.now,
isCollapsed = isCollapsed,
batteryEstimate = state.estimateFor(device),
onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) }
} else null,
@@ -351,18 +356,25 @@ fun OverviewScreen(
}
}
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
if (state.showTroubleshootSuggestion) {
item(key = "troubleshoot_suggestion") {
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
}
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
}
// 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) {
if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") {
UnmatchedDevicesCard(
count = state.unmatchedDevices.size,
count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched,
)
@@ -370,7 +382,7 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) {
itemsIndexed(
items = state.unmatchedDevices,
items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device ->
PodDeviceCard(
@@ -397,6 +409,7 @@ private fun PodDeviceCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit,
@@ -407,6 +420,7 @@ private fun PodDeviceCard(
device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -416,6 +430,7 @@ private fun PodDeviceCard(
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
batteryEstimate = batteryEstimate,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
@@ -434,7 +449,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.dualPodFullyLoaded(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
@@ -447,6 +462,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
showReactionsHint = true,
userExpandedIds = setOf("preview-single"),
batteryEstimates = mapOf(
"preview-dual-full" to BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
),
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -23,6 +23,9 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -34,9 +37,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
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
@@ -56,6 +62,7 @@ class OverviewViewModel @Inject constructor(
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -78,6 +85,66 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
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,
@@ -134,9 +201,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow,
overviewUiSettings,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -151,7 +218,10 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -168,9 +238,18 @@ class OverviewViewModel @Inject constructor(
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()
@@ -188,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
@@ -226,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)
@@ -286,6 +375,14 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
/**
* How long the "connected but no live data" condition must hold before the troubleshooter
* hint appears, so it doesn't flash during the gap between an audio connection and the first
* BLE broadcast.
*/
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
}
}
@@ -60,12 +60,14 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant
@@ -76,6 +78,7 @@ fun DualPodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -192,6 +195,7 @@ fun DualPodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
@@ -233,6 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
PodGauge(
@@ -245,6 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
)
}
@@ -299,6 +309,8 @@ private fun PodGauge(
isMicrophone: Boolean,
showMicrophone: Boolean,
modifier: Modifier = Modifier,
timeRemaining: String? = null,
untilCharged: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
@@ -356,16 +368,27 @@ private fun PodGauge(
Spacer(modifier = Modifier.height(6.dp))
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage, with the time-remaining estimate inline next to it
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.alignByBaseline(),
)
if (timeRemaining != null) {
Text(
text = "· $timeRemaining",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline(),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
@@ -380,6 +403,7 @@ private fun PodGauge(
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label),
chargingDetail = untilCharged,
)
}
}
@@ -454,10 +478,49 @@ private fun CaseRow(
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
// enough live samples.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
DualPodsCard(
device = MockPodDataProvider.dualPodFullyLoaded(),
showDebug = false,
now = SystemTimeSource.now(),
isPro = false,
batteryEstimate = BatteryEstimate(
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
),
onDeviceSettings = {},
)
}
@@ -57,10 +57,12 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@@ -73,6 +75,7 @@ fun SinglePodsCard(
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
batteryEstimate: BatteryEstimate? = null,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
@@ -178,6 +181,7 @@ fun SinglePodsCard(
device = device,
showDebug = showDebug,
now = now,
batteryEstimate = batteryEstimate,
onAncModeChange = onAncModeChange,
)
}
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
batteryEstimate: BatteryEstimate?,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// Battery percentage + time remaining, stacked inside the ring
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
val headsetEstimate = batteryEstimate?.headset
if (headsetEstimate != null) {
Text(
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
softWrap = false,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -277,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
AapPodState.ChargingState.CHARGING -> {
// Time-until-charged lives in the charging chip; the in-ring estimate
// always means runtime, so the two can't be confused.
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
?.let { formatBatteryDurationShort(context, it) }
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = untilCharged
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
?: stringResource(R.string.pods_charging_label),
)
}
else -> Unit
}
if (device.isBeingWorn == true) {
@@ -333,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
batteryEstimate = BatteryEstimate(
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
@@ -0,0 +1,71 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Troubleshoot
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Troubleshoot,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onTroubleShooter,
modifier = Modifier.align(Alignment.End),
) {
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
}
}
}
}
@Preview2
@Composable
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
TroubleshootSuggestionCard(onTroubleShooter = {})
}
@@ -71,6 +71,7 @@ fun StatusChipRow(
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
chargingDetail: String? = null,
) {
FlowRow(
modifier = modifier.animateContentSize(),
@@ -82,9 +83,11 @@ fun StatusChipRow(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingOptimizedLabel,
)
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
// estimate line under the percentage always means runtime, so the two can't be confused.
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
)
else -> Unit
}
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
chargingDetail = "25m",
)
}
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,6 +59,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
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) },
@@ -75,6 +77,7 @@ fun GeneralSettingsScreen(
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
@@ -197,6 +200,21 @@ fun GeneralSettingsScreen(
},
)
}
item {
SettingsBaseItem(
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 {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
@@ -267,6 +285,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(),
)
@@ -281,6 +300,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -295,5 +315,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -34,6 +34,7 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState,
)
@@ -57,7 +58,8 @@ class GeneralSettingsViewModel @Inject constructor(
},
generalSettings.themeState,
isPro,
) { general, compat, themeState, isPro ->
generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State(
isPro = isPro,
showConnectedNotification = general[0] as Boolean,
@@ -65,6 +67,7 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState,
)
}.asLiveState()
@@ -94,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()) {
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.takeFor
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
fun batteryEstimator(): BatteryEstimator
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
// Empty while the monitor service isn't running — estimates simply stay hidden.
val rawEstimate by config.profileId
?.let { pid ->
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
}
?.collectAsState(initial = null)
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
)
} else {
WidgetRenderState.Message(
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
)
).withLocalizedPreviewEstimates(context)
provideContent {
GlanceWidgetContent(state = previewState, context = context)
@@ -96,9 +96,9 @@ private fun DualPodPreview(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
DeviceLabel(
label = state.deviceLabel,
@@ -111,8 +111,8 @@ private fun DualPodPreview(
BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
DeviceLabel(
label = state.deviceLabel,
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -297,39 +298,54 @@ private fun PodItemRow(
textColor: Color,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: Modifier = Modifier,
) {
Row(
Column(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
fontSize = 12.sp,
color = textColor,
maxLines = 1,
)
}
}
@@ -385,6 +401,8 @@ private fun DeviceLabel(
private fun formatPercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@Preview2
@Composable
private fun PreviewDualTiny11() = PreviewWrapper {
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
@@ -257,37 +258,49 @@ private fun GlancePodItem(
textStyle: TextStyle,
iconTint: ColorFilter,
iconSize: Int = 20,
estimate: String? = null,
estimateBelow: Boolean = false,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
Column(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
if (estimateBelow && estimate != null) {
Text(
text = estimate,
style = textStyle,
maxLines = 1,
)
}
}
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel)
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
}
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
}
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val isAapConnected: Boolean,
val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
enum class BatteryLayout {
TINY_COLUMN,
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
val leftEstimate: String?,
@DrawableRes val rightIcon: Int,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
val rightEstimate: String?,
@DrawableRes val caseIcon: Int,
val casePercent: Float,
val caseCharging: Boolean,
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
val estimate: String?,
) : WidgetRenderState()
data class Message(
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
leftPercent = 0.85f,
leftCharging = false,
leftInEar = true,
leftEstimate = "4h 55m",
rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f,
rightCharging = true,
rightInEar = false,
rightEstimate = "25m",
caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f,
caseCharging = false,
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
estimate = "14h 30m",
)
}
}
/**
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
*/
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
leftEstimate = formatBatteryDurationShort(context, 295),
rightEstimate = formatBatteryDurationShort(context, 25),
)
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
object WidgetRenderStateMapper {
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
hasConfiguredProfile: Boolean,
profileLabel: String?,
layout: BatteryLayout = BatteryLayout.NARROW,
estimate: BatteryEstimate? = null,
): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme)
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
caseIcon = device.caseIcon,
casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true,
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
)
device != null -> WidgetRenderState.Message(
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
}
}
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
@ColorInt
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
val bgColor = theme.backgroundColor
@@ -79,6 +79,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile?.address in connectedAddresses,
)
}
@@ -115,6 +116,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in state.connectedAddresses,
)
}
@@ -181,6 +183,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
isSystemConnected = profile.address in connectedAddresses,
)
}
@@ -212,6 +215,21 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope)
/**
* Connection-aware "primary" device for display surfaces (ongoing notification, case popup):
* ranks by [PodDevice.tierRank] (system-connected > live > offline) with the user's
* profile-list order as the tiebreaker. Unlike [primaryDevice], this does NOT blindly pick the
* first profile, so a worn/connected pod is surfaced even when a different profile sits higher
* in the list. Shared so multiple display collectors don't each re-run the combine.
*/
val primaryDeviceByTier: Flow<PodDevice?> = combine(
devices,
profilesRepo.profiles,
) { devices, profiles ->
val profileOrder = profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
devices.primaryByTier(profileOrder)
}.replayingShare(appScope)
private val reportedPersistFailures = mutableSetOf<String>()
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
@@ -273,6 +291,7 @@ class DeviceMonitor @Inject constructor(
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
)
}
@@ -26,8 +26,14 @@ class MonitorModeResolver @Inject constructor(
profilesRepo.profiles,
nudgeCapabilityStore.availability,
) { profiles, nudge ->
val primary = profiles.firstOrNull() ?: return@combine MonitorMode.MANUAL
primary.requiredMode(nudge)
// The mode is driven by the first *addressed* profile, not just the first profile. An
// address-less profile can neither host an AAP session (AapAutoConnect requires an address)
// nor auto-connect, so it can only ever resolve to MANUAL. Letting such a profile sit at the
// top of the list and force MANUAL would silently stop the foreground service that keeps the
// process — and thus background AAP/stem controls — alive for an addressed profile below it.
// No addressed profile at all => genuinely nothing to monitor => MANUAL.
profiles.firstOrNull { !it.address.isNullOrBlank() }?.requiredMode(nudge)
?: MonitorMode.MANUAL
}
.distinctUntilChanged()
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
@@ -60,6 +60,8 @@ data class PodDevice(
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(),
/** Whether the dashboard battery time-remaining estimate is enabled for this device (per-profile). */
val batteryEstimateEnabled: Boolean = true,
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
val isSystemConnected: Boolean = false,
) {
@@ -181,18 +183,23 @@ data class PodDevice(
).minOrNull()
}
// Charging — AAP preferred, BLE fallback, then cached
// Charging — AAP preferred, BLE fallback, then cached. The cached fallback only applies while
// the device is live (connected): charging is a real-time claim ("power is flowing now") that
// we can't stand behind once the device is out of range, so we suppress it after full
// disconnect rather than showing a frozen charging state on the dimmed "cached N ago" card.
// The raw charging bits are still persisted in `cached` for callers that want the last-known
// value directly (device.cached?.is…Charging).
val isLeftPodCharging: Boolean?
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging?.takeIf { isLive }
val isRightPodCharging: Boolean?
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging?.takeIf { isLive }
val isCaseCharging: Boolean?
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging?.takeIf { isLive }
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging?.takeIf { isLive }
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
@@ -267,6 +274,14 @@ data class PodDevice(
val isEitherPodInEar: Boolean?
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
/**
* True when ear detection comes from a live AAP reading. The BLE ear-detection bits are
* unreliable for pods resting in the case (they report phantom "in ear" and disagree
* between the two pods' advertisements), so consumers that must not act on a false "worn"
* gate on this.
*/
val hasAapEarDetection: Boolean get() = aap?.aapEarDetection != null
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
@@ -5,8 +5,13 @@ package eu.darken.capod.monitor.core
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
* Backs [DeviceMonitor.primaryDeviceByTier], used by display surfaces (ongoing notification,
* case popup) that should follow the worn/connected pod.
*
* Distinct from [DeviceMonitor.primaryDevice], which is intentionally non-tier-ranked: address-
* and eligibility-gated reaction flows (auto-connect targets a not-yet-connected device; sleep and
* conversation match by the event's source address) must keep "first profiled device" semantics,
* which tier ranking would break.
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
@@ -9,20 +9,22 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -37,6 +39,21 @@ class AapAutoConnect @Inject constructor(
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
/**
* Per-address count of consecutive connection attempts that never reached READY (failed socket
* connects and handshake-timeout disconnects). Drives the escalating reconnect cooldown so a
* device that keeps stalling its handshake in a crowded RF environment doesn't churn a tight
* reconnect loop. Reset to 0 once a session works (a was-ready disconnect) or the device leaves.
*/
private val preReadyFailures = ConcurrentHashMap<String, Int>()
/** Cooldown (ms) to wait before the next attempt, given how many consecutive pre-READY failures we've seen. */
private fun cooldownFor(address: String): Long {
val failures = preReadyFailures[address] ?: 0
if (failures <= 0) return 0L
return HANDSHAKE_BACKOFF[minOf(failures - 1, HANDSHAKE_BACKOFF.lastIndex)]
}
fun monitor(): Flow<Unit> = merge(
initialConnect(),
reconnectOnDisconnect(),
@@ -74,104 +91,150 @@ class AapAutoConnect @Inject constructor(
return
}
// Escalating cooldown carried over from prior failed attempts (socket failures or handshake
// stalls). 0 on the first attempt so a healthy device connects without delay.
val cooldown = cooldownFor(address)
if (cooldown > 0) {
log(TAG) { "AAP initial connect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
delay(cooldown)
}
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address" }
return
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Bail out if classic BT disconnected
// Bail out if classic BT disconnected — device left, clear the penalty
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
break
preReadyFailures.remove(address)
return
}
val retryState = aapManager.allStates.value[address]
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
break
return
}
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
return
} catch (retryException: Exception) {
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
}
}
// Every attempt this episode failed at the socket level — escalate the next round's cooldown.
preReadyFailures.merge(address, 1, Int::plus)
}
}
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
.onEach { address ->
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return@onEach
}
try {
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
break
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
break
}
// Check if still classically connected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
break
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.meta?.profile?.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
break
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
break
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
} finally {
activeReconnects.remove(address)
}
// Process each disconnect on its own child coroutine so a long per-address backoff never blocks
// another device's reconnect (the collector would otherwise serialise events). channelFlow gives
// us a scope to launch into; it intentionally emits nothing — it stays subscribed for its lifetime.
private fun reconnectOnDisconnect(): Flow<Unit> = channelFlow<Unit> {
aapManager.disconnectEvents.collect { event ->
launch { handleReconnect(event) }
}
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.setupCommonEventHandlers(TAG) { "reconnect" }
}.setupCommonEventHandlers(TAG) { "reconnect" }
private suspend fun handleReconnect(event: AapDisconnectEvent) {
val address = event.address
// A session that actually worked resets the penalty (prompt reconnect). One that never reached
// READY is a failed handshake/short session — escalate so a persistent stall backs off.
if (event.wasEverReady) {
preReadyFailures.remove(address)
} else {
preReadyFailures.merge(address, 1, Int::plus)
}
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return
}
try {
// Escalating cooldown before the normal retry cadence when handshakes keep stalling.
val cooldown = cooldownFor(address)
if (cooldown > 0) {
log(TAG) { "AAP reconnect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
delay(cooldown)
}
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still classically connected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
preReadyFailures.remove(address)
return
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.meta?.profile?.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
preReadyFailures.remove(address)
return
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
return
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
return
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
// Every attempt threw at the socket level (the device is still present but won't accept a
// socket) — escalate so we don't hammer it. Mirrors connectWithRetries. Handshake stalls
// don't reach here: their reconnect socket succeeds and we return above; they escalate via
// the never-ready disconnect event instead.
preReadyFailures.merge(address, 1, Int::plus)
} finally {
activeReconnects.remove(address)
}
}
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
@@ -224,5 +287,12 @@ class AapAutoConnect @Inject constructor(
companion object {
private val TAG = logTag("Monitor", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
/**
* Escalating cooldown (ms) applied before a reconnect/connect attempt, indexed by
* consecutive pre-READY failures minus one. Caps at 60s so a device that perpetually stalls
* its handshake in crowded RF settles into a slow ~minute cadence instead of a tight loop.
*/
internal val HANDSHAKE_BACKOFF = longArrayOf(5_000, 15_000, 30_000, 60_000)
}
}
@@ -0,0 +1,120 @@
package eu.darken.capod.monitor.core.battery
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
*/
@Singleton
class BatteryDrainStore @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val storeDir by lazy {
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
}
private val lock = Mutex()
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
init {
appScope.launch { loadAll() }
}
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
val dir = storeDir
if (!dir.exists()) return@withLock
val loaded = mutableMapOf<ProfileId, DrainProfile>()
dir.listFiles()
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
?.forEach { file ->
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
try {
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
file.delete()
}
}
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
_profiles.value = loaded
}
}
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
val file = id.toFile()
val tmp = File(file.parentFile, "${file.name}.tmp")
try {
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
if (!tmp.renameTo(file)) {
// renameTo can fail across some filesystems; fall back to a direct write.
file.writeText(tmp.readText())
tmp.delete()
}
_profiles.value += (id to profile)
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
tmp.delete()
}
}
}
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
val file = id.toFile()
if (file.exists() && !file.delete()) {
// In-memory is cleared regardless; warn so a failed delete (which would resurrect the
// rate on the next loadAll()) is at least visible rather than silently undoing a reset.
log(TAG, Logging.Priority.ERROR) { "delete($id): failed to remove $file" }
}
_profiles.value -= id
}
}
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
storeDir.listFiles()?.forEach { it.delete() }
_profiles.value = emptyMap()
}
}
companion object {
private val TAG = logTag("Monitor", "BatteryDrainStore")
private const val PREFIX = "drainrate_"
private const val SUFFIX = ".json"
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.monitor.core.battery
/**
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
* [right] for dual-pod devices. Slots with no usable estimate are null.
*/
data class BatteryEstimate(
val left: Pod? = null,
val right: Pod? = null,
val headset: Pod? = null,
) {
/**
* @property minutesRemaining smoothed estimate of minutes until this pod empties
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
* bounded by the model's rated life
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
* the gauge line as the runtime projection.
*/
data class Pod(
val minutesRemaining: Int,
val fractionPerHour: Float,
val source: Source,
val minutesUntilCharged: Int? = null,
) {
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
val isProvisional: Boolean get() = source == Source.SPEC
}
/** Where a pod's drain rate was derived from, in order of increasing confidence. */
enum class Source {
/** Apple's published rating — shown immediately on connect, before any drain is observed. */
SPEC,
/** Persisted rate from earlier sessions with this device. */
LEARNED,
/** Measured from the current session's observed drain. */
LIVE,
}
val hasAny: Boolean get() = left != null || right != null || headset != null
}
@@ -0,0 +1,40 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.PodDevice
/**
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
* device is live (no estimates for cached/offline cards), and it maps to a profile.
*/
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
device.batteryEstimateEnabled && device.isLive && device.profileId != null
}
/**
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
* widget, notification) so the display rules can't drift apart.
*/
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
device.profileId?.let { this[it] }?.takeFor(device)
/**
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
* projection. [charging] should be the same flag that drives the visible charging indicator,
* so the shown duration never contradicts the bolt icon next to it.
*/
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
charging -> minutesUntilCharged
else -> minutesRemaining
}
/**
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
* anything. Everything else on the estimate (rates, source) churns without visible effect —
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
*/
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
left?.displayMinutes(device.isLeftPodCharging == true),
right?.displayMinutes(device.isRightPodCharging == true),
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
).takeUnless { key -> key.all { it == null } }
@@ -0,0 +1,722 @@
package eu.darken.capod.monitor.core.battery
import android.media.AudioManager
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* Learns each device's battery drain rate from observed levels over time and turns it into a
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
* is available immediately on reconnect.
*
* Lifecycle: [monitor] is launched by the foreground monitor service (see
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
*/
@Singleton
class BatteryEstimator @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val drainStore: BatteryDrainStore,
private val timeSource: TimeSource,
private val audioManager: AudioManager,
) {
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
private enum class Slot { LEFT, RIGHT, HEADSET }
/** Which transport a battery reading came from — AAP is 1% granularity, BLE 10%. */
private enum class DataSource { AAP, BLE }
private class SlotHistory {
enum class Direction { DRAIN, CHARGE }
var direction: Direction = Direction.DRAIN
private set
private var source: DataSource? = null
private val samples = ArrayDeque<DrainSample>()
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
val size: Int get() = samples.size
/**
* Keeps the window only while it still describes the same thing: a direction flip
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
*/
fun matches(direction: Direction, source: DataSource): Boolean =
this.direction == direction && this.source == source
fun realign(direction: Direction, source: DataSource) {
if (!matches(direction, source)) samples.clear()
this.direction = direction
this.source = source
}
fun record(sample: DrainSample) {
samples.addLast(sample)
while (samples.size > RING_SIZE) samples.removeFirst()
}
fun clear() = samples.clear()
fun toList(): List<DrainSample> = samples.toList()
}
private class DeviceTracker {
var modeBucket: String = MODE_UNKNOWN
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/**
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
* is the system's audio sink — pure listening segments, the basis for battery health.
*/
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
/** Smoothed displayed minutes, per pod. */
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
var lastUpdateMs: Long? = null
/** When each slot's level last visibly ROSE while charging — drives stall suppression. */
val lastRiseMs: MutableMap<Slot, Long> = mutableMapOf()
// Keyed by "<bucket>/<slot>" for drain rates, "CHARGE/<slot>" for charge rates.
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
/**
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
* during a single session blend against a fixed baseline instead of runaway-converging.
* [sessionBaselineCounts] captures the matching pre-session updateCount, so repeated persists
* within one session count as ONE update, not many.
*/
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
val sessionBaselineCounts: MutableMap<String, Int> = mutableMapOf()
fun clearSlots() = slots.values.forEach { it.clear() }
fun resetWindow() {
clearSlots()
listeningSlots.values.forEach { it.clear() }
pendingListeningFits.clear()
lastMinutes.clear()
lastRiseMs.clear()
sessionBaseline.clear()
sessionBaselineCounts.clear()
}
}
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
// Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate.
private val mutex = Mutex()
/**
* Wipes ALL learned state for [profileId] — the in-memory tracker, the current estimate, and the
* persisted rates — under the same lock as [process], so it's atomic w.r.t. sampling/persisting.
* The next live emission re-seeds the device from its model rating. Safe to call when the monitor
* isn't running (trackers/estimates are already empty; only the store delete has effect).
*/
suspend fun reset(profileId: ProfileId) = withContext(NonCancellable) {
// NonCancellable so a cancelled caller (e.g. the settings screen closing) can't leave the
// wipe half-applied — memory cleared but persisted rates surviving to resurrect later.
mutex.withLock {
log(TAG) { "reset($profileId)" }
trackers.remove(profileId)
_estimates.value = _estimates.value - profileId
drainStore.delete(profileId)
}
}
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices -> process(devices) }
.onCompletion {
// The collector stops with the monitor service; drop session state so the UI can't
// keep showing a stale estimate and the next session re-seeds from persistence.
trackers.clear()
_estimates.value = emptyMap()
}
.map { }
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
private suspend fun process(devices: List<PodDevice>) = mutex.withLock {
// Only profiles with a single, unambiguous live candidate that has the estimate enabled.
// DeviceMonitor keeps multiple same-profile devices when there's no IRK-verified match, and
// blending two physical devices' levels would be garbage — skip those. A device with the
// feature disabled is skipped entirely (not sampled, not persisted).
val unambiguous = devices
.filter { it.profileId != null && it.isLive && it.batteryEstimateEnabled }
.groupBy { it.profileId!! }
.filter { (_, group) -> group.size == 1 }
.mapValues { (_, group) -> group.single() }
val next = _estimates.value.toMutableMap()
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
next.keys.retainAll(unambiguous.keys)
// Sampled once per emission — the gate for health-grade "listening" segments.
val musicActive = audioManager.isMusicActive
for ((profileId, device) in unambiguous) {
val estimate = updateTracker(profileId, device, musicActive)
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
}
_estimates.value = next
}
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
val nowMs = timeSource.elapsedRealtime()
val bucket = device.modeBucket()
// A long gap since the last update means the device was out of range / reconnected — the
// prior window is stale and must not be extended across the absence.
val gap = tracker.lastUpdateMs?.let { nowMs - it }
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
tracker.lastUpdateMs = nowMs
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
// then start fresh so OFF-rate samples never blend into ON-rate.
if (tracker.modeBucket != bucket) {
persistFromWindow(profileId, tracker, device, tracker.modeBucket, nowMs, force = true)
tracker.resetWindow()
tracker.modeBucket = bucket
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
val charging = device.liveCharging(slot)
val reading = device.liveReading(slot)
when {
reading == null -> { // unavailable reading → just drop this slot's window
history.clear()
tracker.lastRiseMs.remove(slot)
// A charging jump breaks discharge continuity even when the level is unreadable.
if (charging == true) tracker.lastMinutes.remove(slot)
}
charging == true -> { // battery rising → sample the CHARGE, never a drain
val (fraction, source) = reading
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop discharge smoothing
if (device.liveChargingOptimized(slot)) {
// Optimized Battery Charging parks the level below full for hours while
// still flagged charging — fitting that plateau would learn garbage.
history.clear()
tracker.lastRiseMs.remove(slot)
} else {
history.realign(SlotHistory.Direction.CHARGE, source)
val last = history.lastFraction
when {
last == null -> { // charge session starts (or resumes) for this slot
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction > last + EPSILON -> {
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
fraction < last - EPSILON -> { // level DROPPED while charging → reseat/swap
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastRiseMs[slot] = nowMs
}
else -> Unit // ~unchanged; stall detection judges the silence
}
}
}
else -> { // draining (or unknown charging state — treated as draining, as before)
val (fraction, source) = reading
tracker.lastRiseMs.remove(slot)
history.realign(SlotHistory.Direction.DRAIN, source)
val last = history.lastFraction
when {
last == null -> history.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
history.clear()
history.record(DrainSample(nowMs, fraction))
tracker.lastMinutes.remove(slot)
}
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
else -> Unit // ~unchanged, no new information
}
}
}
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
// and this device the audio sink (isMusicActive alone would count phone-speaker
// playback). The moment the gate breaks, the finished segment's fit is captured for
// persistence and the window cleared; mixed idle/listening samples would flatten the
// slope and re-dilute health.
val listening = charging != true && reading != null &&
musicActive && device.isSystemConnected && device.wornForSlot(slot)
val listeningHistory = tracker.listeningSlots.getValue(slot)
if (listening) {
val (fraction, source) = reading!!
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
// than letting realign silently discard it.
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
captureListeningSegment(tracker, slot)
}
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
val last = listeningHistory.lastFraction
when {
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
listeningHistory.clear()
listeningHistory.record(DrainSample(nowMs, fraction))
}
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
else -> Unit
}
} else {
captureListeningSegment(tracker, slot)
}
}
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
return computeEstimate(profileId, tracker, device, bucket, nowMs)
}
/** Computes an independent estimate for each pod (left / right / headset). */
private fun computeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
): BatteryEstimate? {
val estimate = BatteryEstimate(
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT, nowMs),
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT, nowMs),
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET, nowMs),
)
return estimate.takeIf { it.hasAny }
}
private fun slotEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
slot: Slot,
nowMs: Long,
): BatteryEstimate.Pod? {
val fraction = device.liveFraction(slot) ?: return null
val spec = device.specRate(bucket)
// While charging the battery is rising, so there's no live drain to fit — project the runtime
// "if used now" from the learned rate or the model rating instead. Live sampling is skipped
// here (and updateTracker already clears the window while charging), so a rising level is
// never learned as a drain — we only surface a projection so the estimate stays visible in
// the case, climbing as the pod charges.
val charging = device.liveCharging(slot) == true
val live = if (charging) {
null
} else {
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
?.takeIf { plausibleForModel(it, spec) }
}
val learned = learnedRate(profileId, device, bucket, slot)
val displayRate = live ?: learned ?: spec ?: return null
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
// source: a degraded battery only ever drains FASTER than new-condition spec, so a measured
// rate normally wins — spec only bites when a source implausibly implies MORE life than Apple.
val effectiveRate = spec?.let { maxOf(displayRate, it) } ?: displayRate
val minutes = DrainModel.minutesRemaining(fraction, effectiveRate) ?: return null
// Smooth against the last displayed value — but while charging neither read nor write that
// state: the in-case projection is shown raw and must not pollute the discharge history, else
// the first estimate after undocking would blend against a stale charging projection.
var smoothed = DrainModel.blendMinutes(if (charging) null else tracker.lastMinutes[slot], minutes)
// Smoothing lags, so just after a drop it can sit above the ceiling — clamp the shown value to
// the spec cap too, so we never DISPLAY more life than Apple rates even mid-transition.
spec?.let { DrainModel.minutesRemaining(fraction, it) }?.let { smoothed = minOf(smoothed, it) }
if (!charging) tracker.lastMinutes[slot] = smoothed
val source = when {
live != null -> BatteryEstimate.Source.LIVE
learned != null -> BatteryEstimate.Source.LEARNED
else -> BatteryEstimate.Source.SPEC
}
return BatteryEstimate.Pod(
minutesRemaining = smoothed,
fractionPerHour = effectiveRate,
source = source,
minutesUntilCharged = if (charging) chargeEstimate(profileId, tracker, device, slot, fraction, nowMs) else null,
)
}
/**
* Minutes until [slot] is full, or null when no usable charge rate exists, the pod is in an
* Optimized Battery Charging hold, or the level has sat still longer than one visible step
* should take (stall — trickle phase or an unreported hold; a linear ETA would just freeze).
*/
private fun chargeEstimate(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
slot: Slot,
fraction: Float,
nowMs: Long,
): Int? {
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
val history = tracker.slots.getValue(slot)
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
val learnedScalar = learnedChargeRate(profileId, device, slot)
val specRate = device.model.batterySpec?.chargeFractionPerHour
// Per band: this session's in-band fit, then the learned band rate, then the scalar
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
// measures the bulk phase; scalars already average what was actually observed).
fun rateFor(band: DrainModel.ChargeBand): Float? =
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
?: learnedChargeBand(profileId, device, slot, band)
?: liveScalar
?: learnedScalar
?: specRate?.let { it * band.specMultiplier }
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
?: DrainModel.ChargeBand.TRICKLE
val stallRate = rateFor(currentBand) ?: return null
val lastRise = tracker.lastRiseMs[slot] ?: return null
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
return DrainModel.minutesUntilFull(fraction, ::rateFor)
}
private fun learnedChargeBand(
profileId: ProfileId,
device: PodDevice,
slot: Slot,
band: DrainModel.ChargeBand,
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
/**
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
* persist pass writes it, bypassing cadence) and the window cleared either way.
*/
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
val history = tracker.listeningSlots.getValue(slot)
if (history.size == 0) return
DrainModel.slopeFractionPerHour(history.toList())?.let {
tracker.pendingListeningFits[slot] = it to history.size
}
history.clear()
}
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> isLeftInEar
Slot.RIGHT -> isRightInEar
Slot.HEADSET -> isBeingWorn
} == true
/**
* Persists each pod's live drain or charge rate (whichever direction its window currently
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
* unless [force]d (mode change). Drain rates are keyed per (bucket, slot); charge rates per slot
* only — the ANC mode doesn't apply inside the case.
*/
private suspend fun persistFromWindow(
profileId: ProfileId,
tracker: DeviceTracker,
device: PodDevice,
bucket: String,
nowMs: Long,
force: Boolean,
) {
// A profile tagged with DIFFERENT hardware means the user re-pointed it — its rates don't
// describe this device, so learning starts over instead of blending into foreign history.
val existing = drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
?: DrainProfile()
val spec = device.specRate(bucket)
var rates = existing.rates
var chargeRates = existing.chargeRates
var changed = false
var chargeBands = existing.chargeBands
var listeningRates = existing.listeningRates
// Blend against the rate stored when this session began, captured once per key, so a single
// long session's repeated writes can't dominate prior history by re-blending their own
// output. The captured updateCount keeps a whole session counting as ONE accumulated update.
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
}
return DrainProfile.LearnedRate(
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
sampleCount = samples,
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 0) + 1,
updatedAt = timeSource.now(),
)
}
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
val last = tracker.lastPersistAtMs[cadenceKey]
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
tracker.lastPersistAtMs[cadenceKey] = nowMs
return true
}
for (slot in Slot.entries) {
val history = tracker.slots.getValue(slot)
if (history.direction == SlotHistory.Direction.CHARGE) {
// Whole-session scalar — the fallback basis and the stall-threshold reference.
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
val key = chargeRateKey(slot)
if (cadenceOk(key)) {
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
// Per-band rates — charging is CC/CV, each regime learns its own speed.
for (band in DrainModel.ChargeBand.entries) {
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
val key = "${chargeRateKey(slot)}/${band.name}"
if (!cadenceOk(key)) continue
val stored = chargeBands[slot.name]?.get(band.name)
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
chargeBands = chargeBands + (slot.name to slotBands)
changed = true
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
} else {
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
DrainModel.slopeFractionPerHour(history.toList())
?.takeIf { plausibleForModel(it, spec) }
?.let { fit ->
val key = rateKey(bucket, slot)
if (cadenceOk(key)) {
rates = rates + (key to blended(key, rates[key], fit, history.size))
changed = true
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
// Listening rates (health basis): a just-closed segment persists immediately — it would
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
val pending = tracker.pendingListeningFits.remove(slot)
val (fit, samples) = when {
pending != null -> pending
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
}
if (fit != null && plausibleForModel(fit, spec)) {
val key = rateKey(bucket, slot)
val cadenceKey = "LISTEN/$key"
if (cadenceOk(cadenceKey, always = pending != null)) {
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
changed = true
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
}
}
}
if (changed) {
drainStore.save(
profileId,
existing.copy(
model = device.model.name,
rates = rates,
chargeRates = chargeRates,
chargeBands = chargeBands,
listeningRates = listeningRates,
),
)
}
}
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null
// Exact per-mode learning always wins — a real measurement for THIS mode.
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
// unlearned mode never inflates the estimate past a real sibling measurement.
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
val sibling = siblingScaledRate(profile, device, bucket, slot)
return listOfNotNull(unknown, sibling).maxOrNull()
}
/**
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
*
* Only fires when:
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
* conservative spec-min behaviour), and
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
* or display clamp, so a borrowed rate could over-promise unbounded), and
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
* hardware can't use), falling back to all modes only when the supported list is unavailable.
*
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
* merges the result conservatively with the UNKNOWN reading.
*/
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
if (bucket == MODE_UNKNOWN) return null
val targetSpec = device.specRate(bucket) ?: return null
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
val best = AapSetting.AncMode.Value.entries
.map { it.name }
.filter { it != bucket && (supported == null || it in supported) }
.mapNotNull { sib ->
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
val learned = profile.rates[rateKey(sib, slot)]
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
?: return@mapNotNull null
learned to siblingSpec
}
.maxWithOrNull(
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
.thenBy { -abs(targetSpec - it.second) }
.thenBy { it.first.updatedAt },
) ?: return null
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
}
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
fractionPerHour.takeIf { it.isFinite() && it > 0f }
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
/** The stored profile, ignored entirely when its rates were learned on different hardware. */
private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? =
drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
/** Session-state key for charge windows — namespaced so it can't collide with an ANC bucket. */
private fun chargeRateKey(slot: Slot): String = "CHARGE/${slot.name}"
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
/**
* Apple's rated drain (fraction/hour) for this model in the given ANC bucket, or null when the
* model has no published rating (Beats / unknown). Used to seed an estimate before any drain is
* observed and as the upper bound on remaining life. When the mode isn't known yet (BLE-only, or
* a just-connected AAP session) the shorter of the two ratings is used, so an unknown mode can't
* over-promise.
*/
private fun PodDevice.specRate(bucket: String): Float? {
val spec = model.batterySpec ?: return null
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
val hours = when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
MODE_UNKNOWN -> listOfNotNull(on, off).minOrNull()
else -> on ?: off // ON / TRANSPARENCY / ADAPTIVE
} ?: return null
return if (hours.isFinite() && hours > 0f) 1f / hours else null
}
/**
* A measured rate is trusted only when it isn't absurdly faster than the model's rated drain. The
* slow side is intentionally NOT bounded: a genuinely gentle drain is real data worth learning,
* and the spec ceiling already stops a slow rate from over-reporting on screen.
*/
private fun plausibleForModel(rate: Float, spec: Float?): Boolean =
spec == null || rate <= spec * SPEC_BAND_MAX
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
// cache); learning from a re-stamped stale reading would poison the rate.
private fun PodDevice.liveFraction(slot: Slot): Float? = liveReading(slot)?.first
/**
* The slot's live battery fraction plus which transport reported it. The source matters because
* the two granularities (AAP 1%, BLE 10%) can't share a fit window — see [SlotHistory.realign].
*/
private fun PodDevice.liveReading(slot: Slot): Pair<Float, DataSource>? {
val aapValue = when (slot) {
Slot.LEFT -> aap?.batteryLeft
Slot.RIGHT -> aap?.batteryRight
Slot.HEADSET -> aap?.batteryHeadset
}
val (value, source) = when {
aapValue != null -> aapValue to DataSource.AAP
else -> when (slot) {
Slot.LEFT -> (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
Slot.RIGHT -> (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
Slot.HEADSET -> (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
}?.let { it to DataSource.BLE } ?: return null
}
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source }
}
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
}
/** Only AAP reports the Optimized Battery Charging hold; BLE can't distinguish it. */
private fun PodDevice.liveChargingOptimized(slot: Slot): Boolean = when (slot) {
Slot.LEFT -> aap?.leftChargingState
Slot.RIGHT -> aap?.rightChargingState
Slot.HEADSET -> aap?.headsetChargingState
} == AapPodState.ChargingState.CHARGING_OPTIMIZED
companion object {
private val TAG = logTag("Monitor", "BatteryEstimator")
private const val RING_SIZE = 32
private const val EPSILON = 0.001f
private const val MODE_UNKNOWN = DrainProfile.BUCKET_UNKNOWN
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
/** Visible battery step per transport — feeds the granularity-aware stall threshold. */
private const val STEP_AAP = 0.01f
private const val STEP_BLE = 0.10f
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
private const val SPEC_BAND_MAX = 4f
/** A gap longer than this between updates means the device was away — reset its window. */
private const val STALE_GAP_MS = 15 * 60_000L
}
}
@@ -0,0 +1,86 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.math.roundToInt
/**
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
*
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
* the UI.
*/
object BatteryHealth {
/** A learned rate must have accumulated this many separate sessions before it counts. */
const val MIN_UPDATE_COUNT = 3
data class PerPod(
val left: Int? = null,
val right: Int? = null,
val headset: Int? = null,
) {
val hasAny: Boolean get() = left != null || right != null || headset != null
}
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
return PerPod(
left = slotPercent(profile, spec, "LEFT"),
right = slotPercent(profile, spec, "RIGHT"),
headset = slotPercent(profile, spec, "HEADSET"),
).takeIf { it.hasAny }
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
// future-format data and must not feed a health figure.
val parts = key.split('/')
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
(1f / specHours) / rate.fractionPerHour
}
if (ratios.isEmpty()) return null
val sorted = ratios.sorted()
val median = if (sorted.size % 2 == 1) {
sorted[sorted.size / 2]
} else {
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
}
return (median * 100f).roundToInt().coerceIn(1, 100)
}
/**
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
* the shorter one would systematically flatter health, the longer one would slander it.
* Malformed or unrecognized bucket keys yield null (entry is skipped).
*/
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
val on = spec.listeningHoursAncOn
val off = spec.listeningHoursAncOff
return when (bucket) {
AapSetting.AncMode.Value.OFF.name -> off ?: on
AapSetting.AncMode.Value.ON.name,
AapSetting.AncMode.Value.TRANSPARENCY.name,
AapSetting.AncMode.Value.ADAPTIVE.name,
-> on ?: off
DrainProfile.BUCKET_UNKNOWN -> listOfNotNull(on, off).takeIf { it.isNotEmpty() }?.average()?.toFloat()
else -> null
}?.takeIf { it.isFinite() && it > 0f }
}
}
@@ -0,0 +1,262 @@
package eu.darken.capod.monitor.core.battery
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* A single battery observation for one slot (left / right / headset).
*
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
*/
data class DrainSample(
val atElapsedMs: Long,
val fraction: Float,
)
/**
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
* be unit-tested without Android, coroutines, or persistence.
*
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
*/
object DrainModel {
/** Minimum samples before a live regression is trusted. */
const val MIN_SAMPLES = 4
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
const val MIN_SPAN_MS = 3 * 60_000L
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
const val MIN_TOTAL_DROP = 0.03f
/**
* Only samples within this window of the newest one feed the regression. Drops stale points
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
* hours of absence.
*/
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
const val RATE_MIN = 0.02f
const val RATE_MAX = 0.80f
/**
* Charge fits get by with fewer samples than drain fits: a charge session is short (well under
* an hour) and BLE's 10% steps would otherwise need most of the session before a fit exists.
*/
const val MIN_SAMPLES_CHARGE = 3
/** Minimum total rise across the charge window — rejects jitter that isn't a real charge. */
const val MIN_TOTAL_RISE = 0.05f
/** Plausible charge-rate band (fraction/hour): a full charge in 15 minutes .. 4 hours. */
const val CHARGE_RATE_MIN = 0.25f
const val CHARGE_RATE_MAX = 4.0f
/**
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
* tail, so suppression only covers the last sliver where the firmware is about to flip the
* charging flag off at 100% anyway.
*/
const val NEAR_FULL_SUPPRESS = 0.99f
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
const val MIN_SAMPLES_CHARGE_NARROW = 2
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
const val MIN_BAND_RISE = 0.05f
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
/** Estimates above this are implausible and suppressed. */
const val MAX_MINUTES = 24 * 60
/**
* Smoothing for the displayed minutes. Asymmetric on purpose: react quickly when the estimate
* DROPS (less time left — e.g. a degraded battery measured draining faster than its rating, or a
* first live fit undercutting the spec seed) so we don't keep showing more life than the latest
* reading supports, but ease UP slowly to swallow upward noise and stay conservative.
*/
const val MINUTES_ALPHA_DOWN = 0.6f
const val MINUTES_ALPHA_UP = 0.25f
/** Smoothing for the persisted per-mode learned rate. */
const val LEARN_ALPHA = 0.3f
/**
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
*/
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
val recent = recentWindow(samples, MIN_SAMPLES) ?: return null
if (recent.first().fraction - recent.last().fraction < MIN_TOTAL_DROP) return null
// Negative slope == draining; flip to a positive drain rate.
val rate = regressionSlopePerHour(recent)?.let { -it } ?: return null
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
return rate
}
/**
* Least-squares slope of RISING [samples] (a charging pod) as a positive charge rate in
* fraction/hour, with the same window guards as the drain fit but charge-tuned thresholds.
*/
fun chargeSlopeFractionPerHour(samples: List<DrainSample>): Float? {
val recent = recentWindow(samples, MIN_SAMPLES_CHARGE) ?: return null
if (recent.last().fraction - recent.first().fraction < MIN_TOTAL_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
if (!rate.isFinite() || rate < CHARGE_RATE_MIN || rate > CHARGE_RATE_MAX) return null
return rate
}
/**
* The regimes of a lithium CC/CV charge. Constant-current bulk is fast and roughly linear;
* once the cell hits its voltage ceiling the charger switches to constant-voltage and the
* current decays, ending in a slow trickle. One linear rate over-promises badly above ~80%,
* so each band learns its own rate.
*
* Band boundaries and seeds are grounded rather than guessed:
* - The 80% bulk boundary is Apple's own documented behavior for their devices ("uses fast
* charging to quickly reach 80% ... then switches to slower trickle charging",
* apple.com/batteries/why-lithium-ion, mirrored in the iPhone user guide).
* - Battery-charging literature places the CC->CV transition at ~75-85% SoC (lower at high
* charge currents) and describes the CV current as decaying until cutoff, with the
* saturation stage taking a large share of total charge time (Battery University BU-409,
* "Charging Lithium-ion").
* - Two CV bands, not more: the CV current decays continuously, so finer bands would model
* noise while multiplying the state to learn. With the 0.5/0.3 seeds below, the last 20%
* takes ~40% of the seeded total charge time — inside BU-409's reported envelope.
*
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
* bands from them needs the CV haircut. Applied ONLY to the spec seed — measured or learned
* rates already reflect where they were observed.
*/
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
BULK(0.0f, 0.8f, 1.0f),
TAPER(0.8f, 0.9f, 0.5f),
TRICKLE(0.9f, 1.0f, 0.3f),
}
/**
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
* the wide bulk band keeps the regular sample requirement.
*/
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
val recent = samples.filter {
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
}
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
val rate = regressionSlopePerHour(recent) ?: return null
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
val floor = CHARGE_RATE_MIN * band.specMultiplier
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
}
/**
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
* usable rate, or the result is implausible.
*/
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
if (!levelFraction.isFinite() || levelFraction < 0f) return null
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
var totalMinutes = 0.0
for (band in ChargeBand.entries) {
val start = maxOf(levelFraction, band.from)
val missing = band.to - start
if (missing <= 0f) continue
val rate = rateForBand(band) ?: return null
if (rate <= 0f) return null
totalMinutes += missing / rate * 60.0
}
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
}
/**
* How long the level may sit unchanged while charging before the "until charged" estimate is
* considered stalled (Optimized Battery Charging hold, trickle phase, or a dead reading) and
* suppressed. Granularity-aware: at [chargeFractionPerHour] a single visible step of
* [stepFraction] (1% on AAP, 10% on BLE) takes `step/rate` hours — a slow BLE charge legitimately
* shows no change for ~20 minutes, so a fixed timeout would falsely suppress it.
*/
fun chargeStallThresholdMs(chargeFractionPerHour: Float, stepFraction: Float): Long {
if (chargeFractionPerHour <= 0f) return CHARGE_STALL_FLOOR_MS
val stepMs = (stepFraction.toDouble() / chargeFractionPerHour * 3_600_000).roundToLong()
return maxOf(CHARGE_STALL_FLOOR_MS, stepMs * 3 / 2)
}
/** Samples within [MAX_SAMPLE_AGE_MS] of the newest, or null when count/span guards fail. */
private fun recentWindow(samples: List<DrainSample>, minSamples: Int): List<DrainSample>? {
if (samples.size < minSamples) return null
// Restrict to the recent window so a gap before the newest sample can't span the fit.
val newestMs = samples.last().atElapsedMs
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
if (recent.size < minSamples) return null
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
return recent
}
/** Signed least-squares slope (fraction per hour) over [recent]; negative when draining. */
private fun regressionSlopePerHour(recent: List<DrainSample>): Float? {
val first = recent.first()
val n = recent.size.toDouble()
var sumX = 0.0
var sumY = 0.0
var sumXY = 0.0
var sumXX = 0.0
for (s in recent) {
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
val y = s.fraction.toDouble()
sumX += x
sumY += y
sumXY += x * y
sumXX += x * x
}
val denominator = n * sumXX - sumX * sumX
if (abs(denominator) < 1e-9) return null
return ((n * sumXY - sumX * sumY) / denominator).toFloat()
}
/**
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
*/
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
return minutes.takeIf { it in 1..MAX_MINUTES }
}
/**
* Exponential moving average over the displayed minutes, to avoid a jumpy number. Uses the
* asymmetric [MINUTES_ALPHA_DOWN]/[MINUTES_ALPHA_UP] factors by default; pass an explicit [alpha]
* to force a symmetric factor (used by tests).
*/
fun blendMinutes(previous: Int?, next: Int, alpha: Float? = null): Int {
if (previous == null) return next
val a = alpha ?: if (next < previous) MINUTES_ALPHA_DOWN else MINUTES_ALPHA_UP
return (next * a + previous * (1f - a)).roundToInt()
}
/** Exponential moving average over the persisted learned rate across sessions. */
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
if (previous == null) next else next * alpha + previous * (1f - alpha)
}
@@ -0,0 +1,70 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.pods.core.apple.PodModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
*
* [chargeRates] is the charging counterpart, keyed per slot only (`"LEFT"` / `"RIGHT"` /
* `"HEADSET"`) — the ANC mode is irrelevant while a pod sits in the case.
*
* [model] tags which [PodModel] the rates were learned on, so a profile re-assigned to different
* hardware doesn't inherit the old device's rates (see [matchesModel]).
*/
@Serializable
data class DrainProfile(
@SerialName("model") val model: String? = null,
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
/**
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
*/
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
/**
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
* figures, so the battery-health estimate compares against these; the general [rates]
* (which include idle wear) keep powering the time-remaining estimate.
*/
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
) {
@Serializable
data class LearnedRate(
/** Drain (or charge) rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
@SerialName("fractionPerHour") val fractionPerHour: Float,
@SerialName("sampleCount") val sampleCount: Int,
/**
* How many distinct sessions have blended into this rate. [sampleCount] is only the window
* size at the LAST save, so this is the actual accumulated-evidence signal (used e.g. to
* gate the derived battery-health figure).
*/
@SerialName("updateCount") val updateCount: Int = 1,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
/**
* Whether these learned rates apply to [model]. An untagged profile or an UNKNOWN model on
* either side is treated as matching — only a definite known-A vs known-B mismatch (the user
* re-pointed the profile at different hardware) disqualifies the data.
*/
fun matchesModel(model: PodModel): Boolean =
this.model == null ||
model == PodModel.UNKNOWN ||
this.model == PodModel.UNKNOWN.name ||
this.model == model.name
companion object {
/** Bucket key for rates learned while the ANC mode wasn't known (BLE-only sessions). */
const val BUCKET_UNKNOWN = "UNKNOWN"
}
}
@@ -59,6 +59,17 @@ class BlePodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
/**
* Drops all cached observations. The troubleshooter calls this between probe attempts so a
* previous compat combo's cached devices (kept up to [STALE_DEVICE_TIMEOUT]) can't leak into
* the next attempt and falsely satisfy it — including via [preferCaseContextPod], which would
* otherwise hand back a stale snapshot whose timestamp predates the fresh scan.
*/
suspend fun clearDeviceCache() = cacheLock.withLock {
log(TAG) { "clearDeviceCache()" }
deviceCache.clear()
}
/**
* Ephemeral override that disables the proximity-pairing scan filter so
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
@@ -70,6 +81,40 @@ class BlePodMonitor @Inject constructor(
unfilteredOverride.value = enabled
}
/**
* Ephemeral override for the three BLE compatibility options. The troubleshooter uses this to
* probe combinations without writing the user's persisted settings: while set, it fully replaces
* the persisted compat values for the active scan. Resets to null on every process start; the
* troubleshooter is the only writer and always clears it when finished, so clearing it restores
* the user's original settings for free.
*/
private val compatOverride = MutableStateFlow<CompatOverride?>(null)
fun setCompatOverride(override: CompatOverride?) {
log(TAG) { "setCompatOverride($override)" }
compatOverride.value = override
}
data class CompatOverride(
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val indirectCallback: Boolean,
)
/** Persisted compat settings, transparently replaced by [compatOverride] while it is set. */
private val effectiveCompat: Flow<CompatOverride> = combine(
compatOverride,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) { override, filteringDisabled, batchingDisabled, indirectCallback ->
override ?: CompatOverride(
offloadedFilteringDisabled = filteringDisabled,
offloadedBatchingDisabled = batchingDisabled,
indirectCallback = indirectCallback,
)
}
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
@@ -145,22 +190,14 @@ class BlePodMonitor @Inject constructor(
private fun createBleScanner() = combine(
bleScanModeController.scannerMode,
unfilteredOverride,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
effectiveCompat,
) { scannermode, showUnfiltered, compat ->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
disableDirectCallback = compat.indirectCallback,
)
}
.throttleLatest(1000)
@@ -29,10 +29,13 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
import eu.darken.capod.monitor.core.battery.estimateFor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -43,6 +46,8 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.conversation.ConversationReaction
import eu.darken.capod.reaction.core.charged.ChargedReaction
import eu.darken.capod.reaction.core.charged.ChargedReactionNotifications
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
@@ -79,11 +84,14 @@ class MonitorService : Service() {
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var chargedReaction: ChargedReaction
@Inject lateinit var chargedReactionNotifications: ChargedReactionNotifications
@Inject lateinit var conversationReaction: ConversationReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver
@Inject lateinit var batteryEstimator: BatteryEstimator
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -207,7 +215,7 @@ class MonitorService : Service() {
return
}
val deviceFlow = deviceMonitor.primaryDevice()
val deviceFlow = deviceMonitor.primaryDeviceByTier
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() }
.throttleLatest(1000)
@@ -219,16 +227,34 @@ class MonitorService : Service() {
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
}
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
currentDevice to settings
val monitorJob = combine(
deviceFlow,
notificationSettingsFlow,
batteryEstimator.estimates,
) { currentDevice, settings, estimates ->
NotificationInput(
device = currentDevice,
settings = settings,
estimate = currentDevice?.let { estimates.estimateFor(it) },
)
}
.onEach { (currentDevice, settings) ->
// The estimates map churns on fields the notification doesn't display (and for other
// profiles' devices) — only re-notify when something visible changed.
.distinctUntilChangedBy { input ->
Triple(
input.device?.toNotificationKey(),
input.settings,
input.device?.let { input.estimate?.displayKey(it) },
)
}
.onEach { (currentDevice, settings, estimate) ->
latestNotificationSettings = settings
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
notifications.getNotification(
currentDevice,
estimate = estimate,
showHint = settings.useExtraNotification,
),
)
@@ -236,7 +262,7 @@ class MonitorService : Service() {
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
is ExtraNotificationAction.Post -> notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
notifications.getNotificationConnected(action.device),
notifications.getNotificationConnected(action.device, estimate),
)
ExtraNotificationAction.Cancel -> notificationManager.cancel(
MonitorNotifications.NOTIFICATION_ID_CONNECTED
@@ -333,11 +359,33 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
chargedReaction.monitor()
.onEach { event ->
when (event) {
is ChargedReaction.Event.ShowNotification -> chargedReactionNotifications.show(
profileId = event.profileId,
deviceLabel = event.deviceLabel,
thresholdPercent = event.thresholdPercent,
)
is ChargedReaction.Event.CancelNotification ->
chargedReactionNotifications.cancel(event.profileId)
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
.catch { log(TAG, WARN) { "chargedReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
conversationReaction.monitor()
.setupCommonEventHandlers(TAG) { "conversationReaction" }
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
batteryEstimator.monitor()
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -348,6 +396,11 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed")
if (injectionComplete) {
try {
chargedReactionNotifications.cancelAll()
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel charged notifications: ${e.message}" }
}
val snapshot = latestNotificationSettings
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
try {
@@ -398,6 +451,12 @@ internal fun buildMonitorModeState(
hasAapSession = aapStates.isNotEmpty(),
)
internal data class NotificationInput(
val device: PodDevice?,
val settings: NotificationSettings,
val estimate: BatteryEstimate?,
)
private data class NotificationDeviceKey(
val profileId: String?,
val label: String?,
@@ -417,6 +476,7 @@ private data class NotificationDeviceKey(
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
val isBeingWorn: Boolean?,
val batteryEstimateEnabled: Boolean,
val iconRes: Int,
val leftPodIcon: Int,
val rightPodIcon: Int,
@@ -442,6 +502,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
batteryEstimateEnabled = batteryEstimateEnabled,
iconRes = iconRes,
leftPodIcon = leftPodIcon,
rightPodIcon = rightPodIcon,
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.displayMinutes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@@ -68,12 +71,10 @@ class MonitorNotificationViewFactory @Inject constructor(
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
@@ -83,13 +84,13 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
fun createBigContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device, estimate)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
else -> createUnknownDeviceBig(device)
}
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_big
).apply {
@@ -97,7 +98,11 @@ class MonitorNotificationViewFactory @Inject constructor(
val leftPercent = device.batteryLeft
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
setTextViewText(
R.id.pod_left_label,
formatBatteryPercent(context, leftPercent) +
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
)
val isLeftPodCharging = device.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = device.isLeftInEar ?: false
@@ -117,14 +122,18 @@ class MonitorNotificationViewFactory @Inject constructor(
val rightPercent = device.batteryRight
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
setTextViewText(
R.id.pod_right_label,
formatBatteryPercent(context, rightPercent) +
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
)
val isRightPodCharging = device.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = device.isRightInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_big
).apply {
@@ -132,16 +141,18 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
setTextViewText(
R.id.headphones_battery_label,
formatBatteryPercent(context, headsetPercent) +
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
)
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
@@ -154,4 +165,7 @@ class MonitorNotificationViewFactory @Inject constructor(
private fun percentToInt(percent: Float): Int =
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
}
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import javax.inject.Inject
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
private fun getBuilder(
device: PodDevice?,
channelId: String,
showHint: Boolean = false
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): NotificationCompat.Builder {
if (device == null) {
return baseBuilder(channelId).apply {
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomContentView(notificationViewFactory.createContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
setContentTitle("$batteryText ~ $stateText")
setSubText(null)
log(TAG, VERBOSE) { "updatingNotification(): $device" }
}
}
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
fun getNotification(
podDevice: PodDevice?,
estimate: BatteryEstimate? = null,
showHint: Boolean = false,
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
fun getNotificationConnected(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
fun getStartupNotification(): Notification =
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
@@ -14,6 +14,7 @@ enum class PodModel(
@DrawableRes val leftPodIconRes: Int? = null,
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
val batterySpec: BatterySpec? = null,
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
@@ -26,6 +27,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -43,6 +45,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -64,6 +67,7 @@ enum class PodModel(
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -86,6 +90,7 @@ enum class PodModel(
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -117,6 +122,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -142,6 +148,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -175,6 +182,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -208,6 +216,7 @@ enum class PodModel(
hasStemConfig = true,
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -242,6 +251,7 @@ enum class PodModel(
hasSleepDetection = true,
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -262,6 +272,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A2096"), // headphones
),
@@ -279,6 +290,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3184"), // headphones
),
@@ -300,6 +312,7 @@ enum class PodModel(
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
modelNumbers = setOf("A3454"), // headphones
),
@@ -580,4 +593,27 @@ enum class PodModel(
*/
val hasDynamicEndOfCharge: Boolean = false,
)
/**
* Apple's published single-charge listening-time ratings, in hours, used to seed the battery
* time-remaining estimate before observed drain is available and as an upper bound on it. These
* are the POD-ONLY listening figures from Apple's tech-spec pages — NOT the "with charging case"
* aggregate. Left null for models Apple doesn't publish figures for (Beats, unknown), which simply
* fall back to purely observed drain. Figures verified against apple.com tech specs (2026-07).
*/
data class BatterySpec(
/** Listening hours with Active Noise Cancellation / Transparency active. */
val listeningHoursAncOn: Float? = null,
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
val listeningHoursAncOff: Float? = null,
/**
* Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's
* published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of
* listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two
* listening ratings it's normalized against the LONGER one, so the seeded "until charged"
* never under-promises. Seeds the charge ETA on the very first charge, before any charge
* rate has been measured; a live fit takes over within minutes.
*/
val chargeFractionPerHour: Float? = null,
)
}
@@ -54,8 +54,8 @@ class AapConnectionManager @Inject constructor(
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
private val _disconnectEvents = MutableSharedFlow<AapDisconnectEvent>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<AapDisconnectEvent> = _disconnectEvents.asSharedFlow()
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
@@ -75,10 +75,10 @@ class AapConnectionManager @Inject constructor(
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/**
* Emits when a connected device reports a Conversational Awareness speaking transition
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
* the engine drops unknown raw values (see [AapSessionEngine]).
* Emits when a connected device reports a Conversational Awareness transition (START / RESUME /
* HOLD / STOP, AAP command 0x4B). Paired with the origin address so the conversation reaction
* can gate on the primary device. Every well-formed frame is classified and emitted (unknown
* status bytes map to HOLD); only structurally malformed frames are dropped (see [AapSessionEngine]).
*/
private val _conversationalAwarenessEvents =
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
@@ -188,7 +188,7 @@ class AapConnectionManager @Inject constructor(
}
if (!wasIntentional) {
_disconnectEvents.tryEmit(address)
_disconnectEvents.tryEmit(AapDisconnectEvent(address, connection.wasEverReady))
}
// End this collector coroutine (also cancels child key-forwarding coroutine)
@@ -217,3 +217,13 @@ class AapConnectionManager @Inject constructor(
connection.send(command)
}
}
/**
* Emitted on an unintentional AAP disconnect. [wasEverReady] tells the reconnect layer whether this
* session ever completed the handshake (reached READY) — a never-ready disconnect is a failed
* handshake/short session and feeds the escalating reconnect backoff; a was-ready drop reconnects promptly.
*/
data class AapDisconnectEvent(
val address: BluetoothAddress,
val wasEverReady: Boolean,
)
@@ -23,12 +23,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
@@ -46,10 +48,14 @@ internal class AapConnection(
private val socketFactory: L2capSocketFactory,
timeSource: TimeSource,
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
private val handshakeTimeout: Duration = DEFAULT_HANDSHAKE_TIMEOUT,
) {
private val engine = AapSessionEngine(profile, timeSource)
/** True once this connection's session reached READY at least once. Survives the engine reset. */
val wasEverReady: Boolean get() = engine.wasEverReady
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
@@ -60,6 +66,7 @@ internal class AapConnection(
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
private val disconnected = AtomicBoolean(false)
private val writeMutex = Mutex()
private val framer = AapFramer()
@@ -109,6 +116,27 @@ internal class AapConnection(
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
// Handshake watchdog: the socket can connect and the handshake be sent, but if the peer
// never replies the engine sits in HANDSHAKING forever (blocking read, no deadline).
// Bound it: if neither READY nor DISCONNECTED is reached in time, tear the socket down so
// the reconnect path can recover. READY / an earlier DISCONNECTED end the wait with no action.
scope.launch {
val settled = withTimeoutOrNull(handshakeTimeout) {
state.first {
it.connectionState == AapPodState.ConnectionState.READY ||
it.connectionState == AapPodState.ConnectionState.DISCONNECTED
}
}
// Re-check after the timeout: READY may have landed in the boundary race between
// withTimeoutOrNull cancelling and us getting here — don't tear down a live session.
if (settled == null && state.value.connectionState != AapPodState.ConnectionState.READY) {
log(TAG, Logging.Priority.WARN) {
"Handshake timed out after $handshakeTimeout for ${device.address} — disconnecting"
}
disconnect()
}
}
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
cleanupSocket()
@@ -118,6 +146,9 @@ internal class AapConnection(
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
// Idempotent: the handshake watchdog and the manager's DISCONNECTED observer can both reach
// here for the same dying session. Run the teardown exactly once.
if (!disconnected.compareAndSet(false, true)) return@withContext
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
@@ -261,6 +292,13 @@ internal class AapConnection(
companion object {
private const val PSM = 0x1001
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
/**
* Upper bound on the post-handshake wait for the first sign of life (READY). Normal
* handshakes complete in well under 2s; 10s tolerates a congested 2.4 GHz band before
* giving up so the reconnect path can recover instead of wedging in HANDSHAKING forever.
*/
internal val DEFAULT_HANDSHAKE_TIMEOUT = 10.seconds
private val TAG = logTag("AAP", "Connection")
}
}
@@ -86,6 +86,15 @@ internal class AapSessionEngine(
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
private var runtimeState = EngineRuntimeState()
/**
* True once this session has reached [AapPodState.ConnectionState.READY] at least once.
* Deliberately NOT cleared by [reset] — consumers read it at disconnect time to tell a
* working session that dropped apart from one that never completed the handshake (drives
* the reconnect backoff). Cleared only when a fresh session starts.
*/
private var _wasEverReady: Boolean = false
val wasEverReady: Boolean get() = _wasEverReady
fun start(scope: CoroutineScope) {
dispatch(AapEngineEvent.SessionStarted(scope))
}
@@ -130,6 +139,7 @@ internal class AapSessionEngine(
when (event) {
is AapEngineEvent.SessionStarted -> {
scope = event.scope
_wasEverReady = false
runtimeState = runtimeState.copy(handshakeResponseReceived = false)
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
}
@@ -159,11 +169,14 @@ internal class AapSessionEngine(
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
if (packet.status != 0) {
log(TAG, ERROR) {
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
"ConnectResponse rejected (status=0x${"%04X".format(packet.status)}) — tearing down: " +
"major=${packet.major} minor=${packet.minor} " +
"features=0x${"%016X".format(packet.features.toLong())}"
}
_state.value = _state.value.copy(connectResponseStatus = packet.status)
// Hard protocol rejection: don't wait out the handshake watchdog — disconnect now so
// the reconnect path can recover (or back off). Nested dispatch mirrors the existing
// inbound-update re-dispatch pattern.
dispatch(AapEngineEvent.ResetRequested)
return
}
@@ -192,6 +205,7 @@ internal class AapSessionEngine(
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
) {
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
_wasEverReady = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
@@ -309,8 +323,8 @@ internal class AapSessionEngine(
}
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
// conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
// conversation reaction: START / RESUME / HOLD / STOP. The decoder already dropped malformed
// frames (rawValue stays null only in that case). The raw payload remains logged.
if (value is AapSetting.ConversationalAwarenessState) {
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
}
@@ -102,10 +102,11 @@ sealed class AapSetting {
/**
* Push-only from device — reports speaking detection state (command 0x4B).
*
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values.
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` while
* the wearer is talking — onset (`1`, `2`) or resumed after a pause (`5`); every other value
* (`0`, `3`, `4`, `0x0B`, …) is `false`. START/RESUME/HOLD/STOP classification for the reaction
* lives in [ConversationAwarenessEvent].
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
@@ -3,27 +3,46 @@ package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
*
* Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project):
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage)
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction)
* Status-byte mapping, derived from a labelled capture set across AirPods Pro 3 and Pro 2 USB-C
* (`protocol-research/conversationalawareness/` — both models share one firmware train and emit
* byte-identical sequences). Each scenario was captured with a known action (normal talking,
* bursty talking, single-pod, volume-up abort, pod removal/case):
* - `1`, `2` → [START] (conversation onset / wind-up → engage the reaction). A conversation always
* opens `1` then `2`; re-onset only ever happens after a terminal.
* - `5` → [RESUME] (speech resumed after a pause; the wind-down was aborted, CA stays engaged). In
* bursty speech the pod cycles `3,5,3,5,…`; `5` is NEVER a terminal. Misreading `5` as a stop was
* the root cause of the premature-resume bug — see [ConversationReaction].
* - `6`, `8`, `9` → [STOP] (conversation ended → disengage). The usual terminal is the `8`→`9`
* pair; `6` is a standalone terminal seen live on Pro 2 USB-C fw `…6814`. Pod removal / case-close
* also emit `8`,`9` (sometimes with no prior `1`,`2`).
* - any other value (`3` pause, `4` / `0x0B` wind-down, `7` abort, and anything unrecognised)
* → [HOLD]: a transitional "possible/real wind-down" frame. The real wind-down runs `3→0x0B→4`
* then the `8,9` terminal; `7` precedes an aborted terminal. Unknown values are deliberately
* classified as HOLD (arm the safety fuse, never resume immediately) rather than guessed at.
*
* The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]).
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
* A HOLD means the wind-down may have begun; with only ONE pod worn the `8,9` terminal is
* deterministically dropped — the flurry ends on `4` (#608, reproduced on Pro 3 and Pro 2) — so
* [ConversationReaction] treats a HOLD as "terminal imminent" and arms a short fuse, with a long
* stale backstop for a fully-dropped flurry. A RESUME (`5`) cancels that fuse and re-arms the long
* backstop; trailing frames after a terminal are ignored once disengaged.
*/
enum class ConversationAwarenessEvent {
START,
RESUME,
HOLD,
STOP,
;
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val RESUME_STATUSES = setOf(5)
val STOPPED_STATUSES = setOf(6, 8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
in RESUME_STATUSES -> RESUME
in STOPPED_STATUSES -> STOP
else -> HOLD
}
@@ -197,8 +197,11 @@ class DefaultAapDeviceProfile(
p[3].toInt() and 0xFF
else -> return null
}
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
// speaking = the wearer is currently speaking: onset (1,2) or resumed after a pause (5).
// See ConversationAwarenessEvent. (Consumers read rawValue for full classification; this
// bool just exposes "is talking now", so a resume must count as speaking too.)
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES ||
status in ConversationAwarenessEvent.RESUME_STATUSES
return AapSetting.ConversationalAwarenessState::class to
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
}
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
else context.getString(R.string.general_value_not_available_label)
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
val hours = minutes / 60
val mins = minutes % 60
return when {
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
}
}
@DrawableRes
fun getBatteryDrawable(percent: Float): Int = when {
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
@@ -4,6 +4,14 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import java.time.Duration
/**
* How far back to look when recovering the lid state from a recent in-case-pod broadcast.
* Time-bounded rather than count-bounded: BLE scan batching means a fixed number of frames is not a
* stable time window, and an old reading must not be allowed to resurrect a stale OPEN/CLOSED.
*/
private val MAX_LID_RECOVERY_AGE: Duration = Duration.ofSeconds(2)
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
// A pod broadcasting from inside the case has authoritative case state
// A pod broadcasting from inside the case has authoritative case state. Out-of-case frames
// (one pod removed) report UNKNOWN via DualApplePods.caseLidState and are skipped here.
if (basic.hasCaseContext && basic.caseLidState in setOf(
DualApplePods.LidState.OPEN,
DualApplePods.LidState.CLOSED,
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
return basic.caseLidState
}
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN.
// Check recent history for a sibling broadcast that has case context.
val fromCaseContext = history
.takeLast(4)
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
// stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
// bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
val recentHistory = history
.filterIsInstance<DualApplePods>()
.filter { Duration.between(it.seenLastAt, basic.seenLastAt).abs() <= MAX_LID_RECOVERY_AGE }
val fromCaseContext = recentHistory
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
// No case-context broadcast in recent history — current value is best we have
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
// Last resort: any non-UNKNOWN from history
return history
.takeLast(2)
.filterIsInstance<DualApplePods>()
// Last resort: any non-UNKNOWN from recent history
return recentHistory
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
val caseLidState: LidState
get() = LidState.fromRaw(pubCaseLidState, hasCaseContext)
get() = LidState.fromRaw(
raw = pubCaseLidState,
hasCaseContext = hasCaseContext,
// The lid bit is only trustworthy from a pod broadcasting inside the case (bit 6), or
// while both pods are in the case (bit 2). A bit4-only frame comes from the out-of-case
// pod and carries a stale lid byte (see LidState.fromRaw).
lidReadingReliable = isThisPodInThecase || areBothPodsInCase,
)
/**
* TODO this is glitchy
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
UNKNOWN;
companion object {
fun fromRaw(raw: UByte, hasCaseContext: Boolean): LidState {
/**
* Derives the lid state from the raw lid byte.
*
* The open/closed bit is only meaningful when broadcast by a pod that is itself inside
* the case ([isThisPodInThecase]) or while both pods are in the case ([areBothPodsInCase]).
* When only one pod is in the case and the *other*, out-of-case pod is the one
* broadcasting (bit4-only), its lid byte is stale and decodes to a phantom OPEN even while
* the case is physically shut (verified on AirPods Pro 1 & Pro 3). Such frames must report
* [UNKNOWN] so they don't drive case-open reactions; consumers recover the real state from
* an in-case-pod broadcast instead.
*/
fun fromRaw(raw: UByte, hasCaseContext: Boolean, lidReadingReliable: Boolean): LidState {
if (!hasCaseContext) return NOT_IN_CASE
if (!lidReadingReliable) return UNKNOWN
return when ((raw.toInt() shr 3) and 0x01) {
0 -> OPEN
@@ -16,6 +16,12 @@ data class KnownDevice(
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
/**
* Profile id this device has ever IRK-resolved to, or null if it has only ever been seen as a
* keyless device. Durable — kept even when [history] is trimmed — so the weak history-recovery
* paths can reliably refuse to hand an IRK-backed identity to a foreign same-model frame.
*/
val boundProfileId: String? = null,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
@@ -76,7 +76,8 @@ class PodHistoryRepo @Inject constructor(
val caseIgnored = if (current is DualApplePods) {
val target = current.getCaseMatchMarkings()
knownDevices.values.filter { known ->
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target } &&
known.isIrkConsistentWith(current)
}
} else {
emptySet()
@@ -143,7 +144,7 @@ class PodHistoryRepo @Inject constructor(
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers && it.isIrkConsistentWith(current) }
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
}
@@ -154,6 +155,24 @@ class PodHistoryRepo @Inject constructor(
return recognizedDevice
}
/**
* The weak history paths (fuzzy markers, case-ignored markings) match on low-entropy broadcast
* data — model + battery nibbles + colour — which a stranger's same-model AirPods at a similar
* battery satisfies. Those paths must not let a foreign frame inherit the identity of one of OUR
* keyed (IRK-backed) devices, or the foreign snapshot poisons that device's cache slot and the
* dashboard card glitches (Symptom B).
*
* A [KnownDevice] is "IRK-backed" if it has ever resolved against a profile's IRK
* ([KnownDevice.boundProfileId], latched durably so history trimming can't erode it). Such a
* device may only be claimed via the strong address/IRK paths, or by a current frame that
* resolved to the SAME profile. Keyless devices (never IRK-backed) are unaffected — fuzzy
* recovery still works for them exactly as before.
*/
private fun KnownDevice.isIrkConsistentWith(current: ApplePods): Boolean {
val bound = boundProfileId ?: return true // not IRK-backed -> keyless, weak match allowed
return current.meta.profile?.id == bound
}
fun updateHistory(device: ApplePods) {
val existing = knownDevices[device.identifier]
@@ -162,12 +181,16 @@ class PodHistoryRepo @Inject constructor(
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
// Latch the IRK-resolved profile id (never cleared by an unbound frame), so it survives history trimming.
val boundProfileId = device.meta.profile?.id?.takeIf { device.meta.isIRKMatch }
knownDevices[device.identifier] = if (existing != null) {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery,
boundProfileId = boundProfileId ?: existing.boundProfileId,
)
} else {
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
@@ -177,7 +200,8 @@ class PodHistoryRepo @Inject constructor(
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
lastCaseBattery = history.determineLatestCaseBattery(),
boundProfileId = boundProfileId,
)
}
}
@@ -2,7 +2,9 @@ package eu.darken.capod.pods.core.apple.ble.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
@@ -16,6 +18,17 @@ object ProximityPairing {
return null
}
// Pods also emit type 0x07 frames whose payload does not use the plaintext status
// format, e.g. prefix 0x07 from the identity address on connect (#603). Their bytes
// are garbage at the standard offsets and would create a ghost device.
if (message.data.firstOrNull() != PAIRING_MESSAGE_PREFIX) {
log(TAG, DEBUG) {
val hex = message.data.joinToString(" ") { "%02X".format(it.toInt()) }
"Dropping proximity pairing message with unsupported prefix: [$hex]"
}
return null
}
return ProximityMessage(
type = message.type,
length = message.length,
@@ -49,4 +62,9 @@ object ProximityPairing {
// This is the default message length among official Apple devices, clones may have different length
internal const val PAIRING_MESSAGE_LENGTH = 25
// First payload byte of every known plaintext status broadcast, including pairing mode
internal val PAIRING_MESSAGE_PREFIX = 0x01.toUByte()
private val TAG = logTag("Pod", "Apple", "ProximityPairing")
}
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
@@ -34,6 +35,11 @@ data class AppleDeviceProfile(
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
/** Whether the dashboard battery time-remaining estimate is shown for this device. */
@SerialName("batteryEstimateEnabled") val batteryEstimateEnabled: Boolean = true,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -61,6 +67,9 @@ data class AppleDeviceProfile(
showPopUpOnConnection = showPopUpOnConnection,
conversationAction = conversationAction,
conversationVolumeReduction = conversationVolumeReduction,
notifyWhenCharged = notifyWhenCharged,
chargedThreshold = chargedThreshold,
chargedSlotScope = chargedSlotScope,
)
override fun toString(): String = "AppleDeviceProfile(" +
@@ -76,6 +85,10 @@ data class AppleDeviceProfile(
"showPopUpOnConnection=$showPopUpOnConnection, " +
"conversationAction=$conversationAction, " +
"conversationVolumeReduction=$conversationVolumeReduction, " +
"notifyWhenCharged=$notifyWhenCharged, " +
"chargedThreshold=$chargedThreshold, " +
"chargedSlotScope=$chargedSlotScope, " +
"batteryEstimateEnabled=$batteryEstimateEnabled, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
@@ -11,6 +11,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
import eu.darken.capod.monitor.core.cache.DeviceStateCache
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import kotlinx.serialization.json.Json
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings,
private val deviceStateCache: DeviceStateCache,
private val batteryDrainStore: BatteryDrainStore,
@SerializationCapod private val json: Json,
) {
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" }
deviceStateCache.delete(profileId)
batteryDrainStore.delete(profileId)
}
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
suspend fun clear() = mutex.withLock {
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
deviceStateCache.deleteAll()
batteryDrainStore.deleteAll()
}
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
@@ -1,6 +1,7 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
data class ReactionConfig(
@@ -15,8 +16,17 @@ data class ReactionConfig(
val conversationAction: ConversationAction = ConversationAction.NOTHING,
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
val notifyWhenCharged: Boolean = false,
/** Battery percentage at which the charged notification fires (clamped 50..100 on use). */
val chargedThreshold: Int = DEFAULT_CHARGED_THRESHOLD,
val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
) {
companion object {
const val DEFAULT_CHARGED_THRESHOLD = 100
const val MIN_CHARGED_THRESHOLD = 50
const val MAX_CHARGED_THRESHOLD = 100
const val CHARGED_THRESHOLD_STEP = 10
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
@@ -39,7 +39,9 @@ class AutoConnect @Inject constructor(
combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
Triple(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode)
// Include caseLidState: for the CASE_OPEN condition it is derived from history
// and can flip to OPEN while the selected frame's raw bytes are unchanged.
listOf(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode, it.caseLidState)
},
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
@@ -0,0 +1,199 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.devicesWithProfiles
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReaction @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val profilesRepo: DeviceProfilesRepo,
) {
sealed class Event {
data class ShowNotification(
val profileId: String,
val deviceLabel: String,
val thresholdPercent: Int,
) : Event()
data class CancelNotification(val profileId: String) : Event()
}
private data class ChargedConfig(val scope: ChargedSlotScope, val thresholdPercent: Int)
fun monitor(): Flow<Event> = flow {
// Machines live in the collection scope: a monitor (service) restart starts fresh sessions.
val machines = mutableMapOf<String, ChargingSessionStateMachine>()
val configs = mutableMapOf<String, ChargedConfig>()
combine(
profilesRepo.profiles,
deviceMonitor.devicesWithProfiles(),
) { profiles, devices -> profiles.filterIsInstance<AppleDeviceProfile>() to devices }
.collect { (profiles, devices) ->
val enabled = profiles.filter { it.notifyWhenCharged }.associateBy { it.id }
// Profile deleted or toggle disabled → reset; a missing device snapshot is
// handled below as staleness, never as a reset.
machines.keys.filter { it !in enabled }.forEach { profileId ->
val output = machines.remove(profileId)!!.process(Input.Reset)
configs.remove(profileId)
if (output == Output.CANCEL) emit(Event.CancelNotification(profileId))
}
for (profile in enabled.values) {
val machine = machines.getOrPut(profile.id) { ChargingSessionStateMachine() }
val thresholdPercent = profile.chargedThreshold.coerceIn(
ReactionConfig.MIN_CHARGED_THRESHOLD,
ReactionConfig.MAX_CHARGED_THRESHOLD,
)
// A persisted CASE scope on a caseless model (restored backup, model change)
// would otherwise produce an eternally-empty slot set.
val scope = if (profile.model.features.hasCase) profile.chargedSlotScope else ChargedSlotScope.PODS
val config = ChargedConfig(scope, thresholdPercent)
val previousConfig = configs.put(profile.id, config)
if (previousConfig != null && previousConfig != config) {
// Scope/threshold changed: stale highwater slots and an already-shown
// notification don't fit the new settings — start evaluation over.
log(TAG, INFO) { "Settings changed for ${profile.id} ($previousConfig -> $config), resetting" }
if (machine.process(Input.Reset) == Output.CANCEL) {
emit(Event.CancelNotification(profile.id))
}
}
val device = devices.firstOrNull { it.profileId == profile.id }
val slots = device?.liveChargingSlots(scope).orEmpty()
// No live slot data (device gone, lid closed, or AAP hasn't pushed battery
// yet) is treated as staleness — the session suspends instead of resetting.
val input = if (device == null || slots.isEmpty()) {
Input.StaleUpdate
} else {
Input.LiveUpdate(
slots = slots,
threshold = thresholdPercent / 100f,
activity = device.chargingActivity(),
)
}
val phaseBefore = machine.phase
val output = machine.process(input)
if (machine.phase != phaseBefore) {
log(TAG, VERBOSE) {
"${profile.id}: $phaseBefore -> ${machine.phase} (output=$output, input=$input)"
}
}
when (output) {
Output.SHOW -> {
val label = device?.label ?: profile.label
log(TAG, INFO) { "Charge complete for ${profile.id} ($label) at $thresholdPercent%" }
emit(Event.ShowNotification(profile.id, label, thresholdPercent))
}
Output.CANCEL -> {
log(TAG, INFO) { "Charging session over for ${profile.id}, cancelling notification" }
emit(Event.CancelNotification(profile.id))
}
Output.NONE -> Unit
}
}
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
companion object {
private val TAG = logTag("Reaction", "Charged")
}
}
/**
* Per-slot live readings only — AAP first, then live BLE. The [PodDevice] facade getters are
* deliberately bypassed because they silently fall back to the disk cache; stale cached values
* must never start, complete, or end a charging session.
*/
/**
* Signals that the user is handling the device, used to dismiss an already-shown notification.
* All sources are live (ear detection: AAP/BLE; lid: BLE; DISCONNECTED slots: AAP) — the cache
* never feeds in here.
*/
internal fun PodDevice.chargingActivity(): ChargingSessionStateMachine.Activity =
ChargingSessionStateMachine.Activity(
// Only trust AAP ear detection — BLE ear bits are phantom for in-case pods (see
// PodDevice.hasAapEarDetection). Unknown when there's no AAP session, which is the
// normal state while charging in the case, so phantom "worn" never dismisses.
wornSlots = when {
!hasAapEarDetection -> null
hasDualPods -> buildSet {
if (isLeftInEar == true) add(Slot.LEFT)
if (isRightInEar == true) add(Slot.RIGHT)
}
else -> if (isBeingWorn == true) setOf(Slot.HEADSET) else emptySet()
},
lid = when (caseLidState) {
DualApplePods.LidState.OPEN -> ChargingSessionStateMachine.Lid.OPEN
DualApplePods.LidState.CLOSED -> ChargingSessionStateMachine.Lid.CLOSED
DualApplePods.LidState.NOT_IN_CASE -> ChargingSessionStateMachine.Lid.NOT_IN_CASE
else -> null
},
disconnectedSlots = buildSet {
if (leftPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.LEFT)
if (rightPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.RIGHT)
if (caseChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.CASE)
if (headsetChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.HEADSET)
},
)
internal fun PodDevice.liveChargingSlots(scope: ChargedSlotScope): Map<Slot, SlotData> {
val slots = mutableMapOf<Slot, SlotData>()
fun add(slot: Slot, battery: Float?, charging: Boolean?) {
if (battery == null || battery < 0f || charging == null) return
slots[slot] = SlotData(battery = battery, isCharging = charging)
}
if (scope != ChargedSlotScope.CASE) {
add(
Slot.LEFT,
aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent,
aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging,
)
add(
Slot.RIGHT,
aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent,
aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging,
)
add(
Slot.HEADSET,
aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent,
aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged,
)
}
if (scope != ChargedSlotScope.PODS) {
add(
Slot.CASE,
aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent,
aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging,
)
}
return slots
}
@@ -0,0 +1,87 @@
package eu.darken.capod.reaction.core.charged
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReactionNotifications @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
) {
init {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reaction_charged_channel_label),
NotificationManager.IMPORTANCE_LOW,
)
)
}
fun show(profileId: String, deviceLabel: String, thresholdPercent: Int) {
if (!notificationManager.areNotificationsEnabled()) {
log(TAG, WARN) { "Notifications disabled — charged notification suppressed" }
return
}
val openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
val text = if (thresholdPercent >= 100) {
context.getString(R.string.reaction_charged_notification_text_full, deviceLabel)
} else {
context.getString(R.string.reaction_charged_notification_text_partial, deviceLabel, thresholdPercent)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.reaction_charged_notification_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openPi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
notificationManager.notify(profileId.toTag(), NOTIFICATION_ID, notification)
}
fun cancel(profileId: String) {
notificationManager.cancel(profileId.toTag(), NOTIFICATION_ID)
}
/**
* Drops every charged notification still on screen. Used when monitoring stops — without a
* running monitor the auto-dismiss-on-unplug promise can't be kept, so don't leave them up.
*/
fun cancelAll() {
notificationManager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.tag?.startsWith(TAG_PREFIX) == true }
.forEach { notificationManager.cancel(it.tag, it.id) }
}
private fun String.toTag() = "$TAG_PREFIX$this"
companion object {
private val TAG = logTag("Reaction", "Charged", "Notifications")
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.charged"
private const val NOTIFICATION_ID = 4
private const val TAG_PREFIX = "charged:"
private const val PENDING_INTENT_REQUEST_CODE = 1
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.reaction.core.charged
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Which battery slots the charged notification watches. */
@Serializable
enum class ChargedSlotScope(
@StringRes val labelRes: Int,
) {
@SerialName("charged.scope.pods") PODS(R.string.settings_charged_scope_pods_label),
@SerialName("charged.scope.case") CASE(R.string.settings_charged_scope_case_label),
@SerialName("charged.scope.podsandcase") PODS_AND_CASE(R.string.settings_charged_scope_both_label),
}
@@ -0,0 +1,287 @@
package eu.darken.capod.reaction.core.charged
/**
* Tracks one charging session for one profile and decides when the "charged" notification
* should be shown or cancelled. Pure Kotlin, no Android/DI/Flow dependencies; instances are
* driven sequentially from a single coroutine (no internal locking).
*
* A session starts when any slot reports charging and collects every slot that charges while
* the session runs. Per-slot battery highwater marks decide completion: a slot that stops
* charging at/above the threshold finished naturally (firmware flips the charging flag off at
* 100%), while a slot that stops below the threshold was genuinely unplugged and resets the
* session. Stale data suspends the session instead of resetting it — brief BLE gaps while the
* device sits on the charger must not lose progress.
*
* After firing, the notification is taken down again when the user demonstrably reacted to it:
* battery discharging below the threshold, a new charging session starting, or any activity
* signal showing the pods are being handled (worn-pod set changed, pod removed from the case,
* case lid moved). Activity is judged against baselines captured at fire time so pre-existing
* state doesn't dismiss — only a change does:
* - Worn pods and DISCONNECTED slots baseline on the firing frame. They only apply to
* sessions containing pod slots — wearing pods says nothing about a case-only charge.
* - The lid signal baselines on the UNION of values seen before the fire (this session plus
* a few frames leading into it). With one pod worn and one charging, both pods broadcast
* and the monitor's dedup alternates between their frames (OPEN from the in-case pod,
* NOT_IN_CASE from the worn pod) — a single-frame baseline would read that steady flapping
* as activity. Lid movement is a handling signal for any session, including case-only.
*
* An activity dismissal latches into [Phase.DISMISSED] so a still-full, still-charging device
* can't immediately re-fire.
*/
class ChargingSessionStateMachine {
enum class Slot { LEFT, RIGHT, CASE, HEADSET }
/** [battery] is a fraction 0..1, matching the monitor layer's battery values. */
data class SlotData(val battery: Float, val isCharging: Boolean)
/**
* Case posture from the BLE lid byte. NOT_IN_CASE isn't a lid position — it means the
* broadcasting pod is physically outside the case, which is just as much a handling
* signal as a lid movement (it's the only BLE-side trace of "pod taken out").
*/
enum class Lid { OPEN, CLOSED, NOT_IN_CASE }
/** Signals that the user is physically handling the device. All optional/unknown-safe. */
data class Activity(
/** Pods currently worn (per-side); null when no ear data at all. */
val wornSlots: Set<Slot>? = null,
val lid: Lid? = null,
/** Slots physically absent from the case (AAP ChargingState.DISCONNECTED). */
val disconnectedSlots: Set<Slot> = emptySet(),
)
sealed interface Input {
/**
* Fresh data from a live source (BLE/AAP). [slots] only contains slots with live
* readings — cached values must never be fed in here. [threshold] is a fraction 0..1.
*/
data class LiveUpdate(
val slots: Map<Slot, SlotData>,
val threshold: Float,
val activity: Activity = Activity(),
) : Input
/** Device has no live data right now (out of range, lid closed, cache-only). */
data object StaleUpdate : Input
/** Toggle disabled, settings changed, or profile deleted. */
data object Reset : Input
}
enum class Output { NONE, SHOW, CANCEL }
enum class Phase { IDLE, CHARGING, FIRED, DISMISSED, SUSPENDED }
var phase: Phase = Phase.IDLE
private set
/** Phase to resume into when live data returns; only meaningful while SUSPENDED. */
private var suspendedFrom: Phase = Phase.IDLE
/** Battery highwater per slot that joined the session. */
private val highwater = mutableMapOf<Slot, Float>()
/**
* Rolling window of the most recent lid observations across all phases. Seeds the at-fire
* baseline so the OPEN/NOT_IN_CASE alternation (worn pod broadcasts NOT_IN_CASE while the
* in-case pod broadcasts OPEN; the monitor's dedup serves them in turn) is absorbed — even
* for an instant fire right after a settings-change reset, since the window is never cleared.
*/
private val recentLids = ArrayDeque<Lid>()
// Activity baselines frozen at fire; see class docs.
private val baselineLids = mutableSetOf<Lid>()
private var baselineWorn: Set<Slot>? = null
private var baselineDisconnected: Set<Slot> = emptySet()
fun process(input: Input): Output {
if (input is Input.LiveUpdate) input.activity.lid?.let(::recordRecentLid)
return when (phase) {
Phase.IDLE -> processIdle(input)
Phase.CHARGING -> processCharging(input)
Phase.FIRED -> processFired(input)
Phase.DISMISSED -> processDismissed(input)
Phase.SUSPENDED -> processSuspended(input)
}
}
private fun processIdle(input: Input): Output {
if (input !is Input.LiveUpdate) return Output.NONE
val charging = input.slots.filterValues { it.isCharging }
if (charging.isEmpty()) return Output.NONE
highwater.clear()
charging.forEach { (slot, data) -> highwater[slot] = data.battery }
phase = Phase.CHARGING
return fireIfComplete(input)
}
private fun processCharging(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.CHARGING)
is Input.LiveUpdate -> {
// Charging slots join the session (e.g. case plugged in later); slots already in
// it advance their mark; slots absent from this update stay frozen.
input.slots.forEach { (slot, data) ->
if (data.isCharging || slot in highwater) {
highwater[slot] = maxOf(highwater[slot] ?: 0f, data.battery)
}
}
val unplugged = highwater.any { (slot, mark) ->
input.slots[slot]?.isCharging == false && mark < input.threshold
}
if (unplugged) reset(cancel = false) else fireIfComplete(input)
}
}
private fun processFired(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = true)
is Input.StaleUpdate -> suspend(Phase.FIRED)
is Input.LiveUpdate -> when {
// "All slots stopped charging" alone is NOT an unplug signal — firmware flips the
// charging flag off at 100% while still on power. Slots that never charged this
// session (e.g. an idle half-empty case) carry no signal either. The session ends
// on: a new charge context, discharge below threshold, or user activity.
isNewChargeContext(input) -> {
// Only CANCEL is reported now; the next update finds IDLE and starts (and, if
// the data already satisfies the threshold, fires) the new session.
phase = Phase.IDLE
highwater.clear()
Output.CANCEL
}
isSessionDischarged(input) -> reset(cancel = true)
isActivityDetected(input.activity) -> {
phase = Phase.DISMISSED
Output.CANCEL
}
else -> Output.NONE
}
}
/** Like FIRED, but the notification is already gone — same exits, silent. */
private fun processDismissed(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.DISMISSED)
is Input.LiveUpdate -> when {
isNewChargeContext(input) -> {
// Nothing to cancel, so unlike FIRED the new session can start right away.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
}
isSessionDischarged(input) -> reset(cancel = false)
else -> Output.NONE
}
}
private fun processSuspended(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = suspendedFrom == Phase.FIRED)
is Input.StaleUpdate -> Output.NONE
is Input.LiveUpdate -> {
phase = suspendedFrom
if (phase == Phase.CHARGING && hasRegressed(input)) {
// Battery dropped well below a session mark while we were blind — the device
// was unplugged and used in the meantime. Start over with the fresh data.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
} else {
process(input)
}
}
}
/** Something is charging below the threshold — a fresh charging session is beginning. */
private fun isNewChargeContext(input: Input.LiveUpdate): Boolean =
input.slots.any { it.value.isCharging && it.value.battery < input.threshold }
/** A session slot is off power and drained below the threshold — device is in use. */
private fun isSessionDischarged(input: Input.LiveUpdate): Boolean = highwater.keys.any { slot ->
input.slots[slot]?.let { !it.isCharging && it.battery < input.threshold } == true
}
private fun isActivityDetected(activity: Activity): Boolean {
// Worn pods and pod removal only speak about pod slots; a case-only session ignores them.
if (highwater.keys.any { it != Slot.CASE }) {
activity.wornSlots?.let { worn ->
when (val baseline = baselineWorn) {
// No ear data at fire time: pods can't be worn while charging in the case,
// so any worn pod is activity — only "nothing worn" is adoptable as baseline.
null -> if (worn.isNotEmpty()) return true else baselineWorn = emptySet()
else -> if (worn != baseline) return true
}
}
val newlyDisconnected = activity.disconnectedSlots.any {
it != Slot.CASE && it in highwater && it !in baselineDisconnected
}
if (newlyDisconnected) return true
}
activity.lid?.let { lid ->
if (baselineLids.isEmpty()) {
// Lid never observed pre-fire: first sighting is the baseline — it was most
// likely in that posture all along (open is the normal charging posture).
baselineLids.add(lid)
} else if (lid !in baselineLids) {
return true
}
}
return false
}
private fun hasRegressed(input: Input.LiveUpdate): Boolean = highwater.any { (slot, mark) ->
input.slots[slot]?.let { it.battery < mark - REGRESSION_TOLERANCE } == true
}
private fun recordRecentLid(lid: Lid) {
recentLids.addLast(lid)
while (recentLids.size > RECENT_LID_FRAMES) recentLids.removeFirst()
}
private fun fireIfComplete(input: Input.LiveUpdate): Output {
if (highwater.isEmpty() || highwater.any { it.value < input.threshold }) return Output.NONE
phase = Phase.FIRED
baselineLids.clear()
baselineLids += recentLids
baselineWorn = input.activity.wornSlots
baselineDisconnected = input.activity.disconnectedSlots.intersect(highwater.keys)
return Output.SHOW
}
private fun reset(cancel: Boolean): Output {
phase = Phase.IDLE
highwater.clear()
// recentLids is an ambient rolling window — intentionally kept across resets so an
// instant re-fire after a settings change still has the recent lid alternation.
baselineLids.clear()
baselineWorn = null
baselineDisconnected = emptySet()
return if (cancel) Output.CANCEL else Output.NONE
}
private fun suspend(from: Phase): Output {
suspendedFrom = from
phase = Phase.SUSPENDED
return Output.NONE
}
companion object {
/**
* Battery drop (fraction) below a slot's highwater mark that counts as a real
* regression on resume. Public BLE reports in 10% steps, so a single-step flicker
* (exactly 0.1) must not reset the session.
*/
private const val REGRESSION_TOLERANCE = 0.1f
/**
* Pre-fire lid observations kept as baseline context. At the observed 0.5-1.5s
* emission cadence this is a few seconds — enough to capture dedup alternation
* leading into an instant fire without dragging in long-stale postures.
*/
private const val RECENT_LID_FRAMES = 8
}
}
@@ -12,6 +12,8 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.profiles.core.ReactionConfig
import kotlinx.coroutines.CoroutineScope
@@ -30,17 +32,38 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
*
* The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset,
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP]
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD)
* resets that timer, so a long conversation stays engaged.
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP],
* status `8,9`). The pod sends NO frames during active speech — it stays engaged for as long as it
* hears nearby voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely
* against ambient noise). So frame-silence must NOT be read as "speaking ended"; after a START only
* the long [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
*
* A [ConversationAwarenessEvent.HOLD] frame (`3` pause, `0x0B`/`4` wind-down, `7` abort) is evidence
* the wind-down may have begun. The real wind-down is a short flurry `3→0x0B→4` then the `8,9`
* terminal — but the terminal is sometimes dropped entirely (single-pod wear: fw `…6589`/`…6861`
* emitted `3,0xB,4` then nothing, stranding the volume low — #608). So a HOLD frame re-arms the
* timer with the short [WIND_DOWN_TIMEOUT] fuse: if no terminal (or resume) follows, speech is
* treated as ended anyway.
*
* A [ConversationAwarenessEvent.RESUME] frame (`5`) means speech resumed after a pause — in bursty
* talking the pod cycles `3,5,3,5,…` while CA stays engaged. It is NOT a terminal: it cancels any
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
*
* Removing/inserting a pod makes the firmware re-key CA — it emits a terminal then a fresh onset
* around the physical change even though the conversation continues. A terminal arriving within
* [EAR_TRANSITION_WINDOW] of an AAP ear-detection change is therefore treated as a re-key artifact:
* it defers to the wind-down fuse instead of disengaging, so a brief pod removal mid-conversation
* neither strands a pause nor causes a duck→restore→re-duck volume blip.
*
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
@@ -68,16 +91,42 @@ class ConversationReaction @Inject constructor(
val at: Long,
)
/** What the single pending [disengageJob] is currently waiting for. */
private enum class TimerPhase {
/** Long backstop while only START/RESUME seen — inferred end if it fires. */
STALE_BACKSTOP,
/** Short fuse armed by a wind-down HOLD — terminal imminent (or dropped, #608). */
WIND_DOWN_FUSE,
/** Brief wait on an uncorroborated cold terminal to see if a pod-removal ear change follows. */
STOP_SETTLE,
}
private val mutex = Mutex()
private var active: Active? = null
private var staleJob: Job? = null
private var disengageJob: Job? = null
private var timerPhase: TimerPhase? = null
// Bumped on every (re)arm. A timer job captures its generation and only acts if still current —
// guards against a stale job that already passed its delay (so cancel() no longer stops it) being
// re-armed to the SAME phase while it blocks on the mutex. This is a runtime concurrency guard: the
// single-threaded virtual-time test harness serializes the timer callback and event handlers, so a
// re-arm there always cancels the prior job before it runs — the race is not reproducible in a unit
// test (a test would exercise cancel(), not this check). Left deliberately uncovered, not an oversight.
private var timerGeneration = 0L
private var idCounter = 0L
// Per-owner AAP ear-detection tracking, so a CA terminal that lands right after a pod
// removal/insertion can be recognised as a re-key artifact rather than a real conversation end.
private val lastEarDetection = mutableMapOf<BluetoothAddress, AapSetting.EarDetection?>()
private val earTransitionAt = mutableMapOf<BluetoothAddress, Long>()
fun monitor(): Flow<Unit> = merge(
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
// (intentional or not) — disconnectEvents only fires for unintentional drops.
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
// Tracks ear-detection transitions (for terminal-artifact suppression) and reverts a stranded
// duck when the owning device leaves the AAP state map for any reason (intentional or not) —
// disconnectEvents only fires for unintentional drops.
aapManager.allStates.onEach { states -> onStatesUpdated(states) },
)
.map { }
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
@@ -86,6 +135,7 @@ class ConversationReaction @Inject constructor(
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
ConversationAwarenessEvent.START -> onSpeakingStart(address)
ConversationAwarenessEvent.RESUME -> onSpeakingResume(address)
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
}
@@ -103,12 +153,17 @@ class ConversationReaction @Inject constructor(
val current = active
if (current != null && current.owner == address) {
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
restartStaleTimer(current)
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
armTimer(current, TimerPhase.STALE_BACKSTOP)
log(TAG) { "START from $address — already active ($action), keep-alive" }
return
}
// A different device started speaking while we were active — undo the old one first.
if (current != null) revert(current, "superseded by $address")
// A different device started speaking while we were active — undo the old one first and
// cancel its pending timer (the no-op engage paths below would otherwise leak it).
if (current != null) {
revert(current, "superseded by $address")
clearActive()
}
when (action) {
ConversationAction.PAUSE -> {
@@ -116,7 +171,7 @@ class ConversationReaction @Inject constructor(
if (paused) {
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
active = record
restartStaleTimer(record)
armTimer(record, TimerPhase.STALE_BACKSTOP)
log(TAG, INFO) { "START on $address → paused media" }
} else {
active = null
@@ -139,7 +194,7 @@ class ConversationReaction @Inject constructor(
timeSource.elapsedRealtime(),
)
active = record
restartStaleTimer(record)
armTimer(record, TimerPhase.STALE_BACKSTOP)
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}${duck.appliedVolume}" }
} else {
active = null
@@ -152,11 +207,30 @@ class ConversationReaction @Inject constructor(
}
}
/** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */
/**
* Speech resumed (status `5`) after a pause — the wind-down was aborted, the wearer is talking
* again. Cancel any armed wind-down fuse and re-arm the long [STALE_TIMEOUT] backstop, exactly
* like a duplicate-START keep-alive, so media stays paused/ducked through the conversation. Does
* NOT engage from scratch: a RESUME with no active session is ignored (a conversation always
* opens with a START, and a stray `5` should never start a pause/duck on its own).
*/
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
armTimer(current, TimerPhase.STALE_BACKSTOP)
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
}
/**
* Transitional wind-down frame (`3` pause, `0x0B`/`4` wind-down, `7` abort). The pod is silent
* during active speech, so this frame means the wind-down may have begun and a terminal frame is
* imminent — but firmware sometimes drops it (#608, single-pod wear). Arm the short fuse: if no
* terminal (or a fresh START / RESUME) follows, disengage anyway.
*/
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
restartStaleTimer(current)
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
}
private suspend fun onSpeakingStop(address: BluetoothAddress) {
@@ -167,13 +241,42 @@ class ConversationReaction @Inject constructor(
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
return
}
clearActive()
disengage(current, primary, "STOP on $address")
// The 0x06 ear-detection frame may have arrived but not yet been processed by the
// allStates collector — fold the latest snapshot in before deciding (in-app ordering race).
refreshEarTransition(address)
// Pulling/inserting a pod makes the firmware re-key CA — a terminal (8,9) then a fresh
// onset (1,2) around the physical change, even though the conversation continues.
when {
// Ear change already seen → re-key artifact. Defer to the fuse; a re-onset cancels it.
isRecentEarTransition(address) -> {
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
log(TAG) { "STOP from $address during ear transition — deferring to fuse" }
}
// Corroborated by a preceding wind-down (3,0xB,4 / abort 7): a real end → resume now.
timerPhase == TimerPhase.WIND_DOWN_FUSE -> {
clearActive()
disengage(current, primary, "STOP on $address", applyAgeGuard = false)
}
// Cold terminal with no wind-down and no ear change yet. On primary-pod removal the
// firmware sends the terminal tens of ms BEFORE the ear-detection frame (measured ~30ms
// on Pro 3), so wait a short settle to see if an ear change lands before treating it as
// a real end.
else -> {
armTimer(current, TimerPhase.STOP_SETTLE)
log(TAG) { "STOP from $address — uncorroborated, settling" }
}
}
}
}
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
/**
* Graceful disengage (STOP event or timer expiry). Must be called under [mutex]. [applyAgeGuard]
* gates resume on [PAUSE_RESUME_WINDOW]: `true` only for the inferred stale-backstop end (we lost
* track and timed out — surprise-resuming long after is worse than a stranded pause); `false` for
* an explicit terminal or the wind-down fuse, where a recent/real end signal makes resume correct.
*/
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String, applyAgeGuard: Boolean) {
when (val kind = record.kind) {
is Kind.Paused -> {
// Resume the pause WE caused, regardless of the current action setting. Gating on
@@ -182,11 +285,11 @@ class ConversationReaction @Inject constructor(
// surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at
when {
age > PAUSE_RESUME_WINDOW_MS ->
applyAgeGuard && age.milliseconds > PAUSE_RESUME_WINDOW ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
primary.isBeingWorn == false ->
wornForResume(primary) == false ->
log(TAG) { "$reason — resume skipped (not worn)" }
mediaControl.isPlaying ->
log(TAG) { "$reason — resume skipped (already playing)" }
@@ -201,14 +304,35 @@ class ConversationReaction @Inject constructor(
}
}
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
private suspend fun onStatesUpdated(states: Map<BluetoothAddress, AapPodState>) = mutex.withLock {
// Record when each device's AAP ear-detection last changed (pod removed/inserted/cased).
for ((address, state) in states) recordEarIfChanged(address, state.aapEarDetection)
lastEarDetection.keys.retainAll(states.keys)
earTransitionAt.keys.retainAll(states.keys)
val current = active ?: return
if (current.owner !in addresses) {
if (current.owner !in states.keys) {
clearActive()
revert(current, "owner ${current.owner} gone")
}
}
/**
* Must be called under [mutex]. Notes an AAP ear-detection transition for [address]. The very
* first observation of a device only seeds the baseline — it is not counted as a transition.
*/
private fun recordEarIfChanged(address: BluetoothAddress, ear: AapSetting.EarDetection?) {
if (lastEarDetection.containsKey(address) && lastEarDetection[address] != ear) {
earTransitionAt[address] = timeSource.elapsedRealtime()
}
lastEarDetection[address] = ear
}
/** Must be called under [mutex]. Folds the latest allStates snapshot in for [address]. */
private fun refreshEarTransition(address: BluetoothAddress) {
recordEarIfChanged(address, aapManager.allStates.value[address]?.aapEarDetection)
}
private suspend fun onMonitorCompleted() = mutex.withLock {
val current = active ?: return
clearActive()
@@ -234,48 +358,133 @@ class ConversationReaction @Inject constructor(
mediaControl.restoreMusicVolume(kind.priorVolume)
}
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
private fun clearActive() {
staleJob?.cancel()
staleJob = null
disengageJob?.cancel()
disengageJob = null
timerPhase = null
active = null
}
/**
* Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame
* (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer
* session.
* Must be called under [mutex]. (Re)arms the single disengage timer for [record] in [phase],
* replacing whatever was pending. On expiry [onTimerExpired] decides per phase. Guarded on both
* [Active.id] and [phase] so a late timer can't act on a newer session or a re-armed phase.
*/
private fun restartStaleTimer(record: Active) {
staleJob?.cancel()
staleJob = appScope.launch {
delay(STALE_TIMEOUT_MS)
private fun armTimer(record: Active, phase: TimerPhase) {
disengageJob?.cancel()
timerPhase = phase
val generation = ++timerGeneration
val timeout = when (phase) {
TimerPhase.STALE_BACKSTOP -> STALE_TIMEOUT
TimerPhase.WIND_DOWN_FUSE -> WIND_DOWN_TIMEOUT
TimerPhase.STOP_SETTLE -> STOP_SETTLE_DELAY
}
disengageJob = appScope.launch {
delay(timeout)
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
if (active?.id == record.id) {
val current = active!!
clearActive()
disengage(current, primary, "stale timeout (frames ceased)")
}
// generation guards against a stale job re-armed to the same phase for the same record.
if (active?.id == record.id && timerGeneration == generation) onTimerExpired(record, phase, primary)
}
}
}
/**
* Must be called under [mutex] from inside the firing timer job. Detaches the slot WITHOUT
* cancelling (we ARE that job — self-cancel could abort a following suspension), then acts:
* - STOP_SETTLE + an ear change appeared meanwhile → re-key artifact, hand off to the fuse.
* - otherwise force-end the session. The age guard applies only to the inferred STALE backstop;
* the wind-down fuse and a settled cold terminal carry real/recent end evidence → resume regardless.
*/
private suspend fun onTimerExpired(record: Active, phase: TimerPhase, primary: PodDevice?) {
disengageJob = null
timerPhase = null
if (phase == TimerPhase.STOP_SETTLE && isRecentEarTransition(record.owner)) {
armTimer(record, TimerPhase.WIND_DOWN_FUSE)
log(TAG) { "STOP settle elapsed for ${record.owner} — ear transition appeared, deferring to fuse" }
return
}
active = null
val reason = when (phase) {
TimerPhase.STALE_BACKSTOP -> "no terminal within backstop"
TimerPhase.WIND_DOWN_FUSE -> "no terminal within wind-down fuse"
TimerPhase.STOP_SETTLE -> "cold terminal settled"
}
disengage(record, primary, reason, applyAgeGuard = phase == TimerPhase.STALE_BACKSTOP)
}
/**
* Whether the pod is worn enough to resume into, honoring One-Pod Mode (mirrors
* [eu.darken.capod.reaction.core.autoconnect.AutoConnect]): with One-Pod Mode on, a single pod
* in ear counts as worn, falling back to the both-pods reading only when the single-pod signal is
* unknown. Returns `null` when ear state is genuinely unknown — the caller treats only an explicit
* `false` as not-worn, so an unknown stays lenient (resumes).
*/
private fun wornForResume(device: PodDevice): Boolean? =
if (device.reactions.onePodMode) device.isEitherPodInEar ?: device.isBeingWorn
else device.isBeingWorn
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
val at = earTransitionAt[address] ?: return false
return (timeSource.elapsedRealtime() - at).milliseconds <= EAR_TRANSITION_WINDOW
}
private fun nextId(): Long = ++idCounter
companion object {
private val TAG = logTag("Reaction", "Conversation")
/**
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation
* between frames.
* Backstop fuse while engaged with no wind-down evidence yet (START/RESUME frames seen).
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
* while the link stays up. Kept longer than [PAUSE_RESUME_WINDOW] so a back-stopped pause
* is never auto-resumed (only a duck is restored — a stranded low volume is the worse
* failure).
*/
private const val STALE_TIMEOUT_MS = 12L * 1000L
private val STALE_TIMEOUT = 5.minutes
/** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L
/**
* Short fuse armed by a transitional [ConversationAwarenessEvent.HOLD] frame: the wind-down
* has begun, so a terminal frame should follow within seconds. With only one pod worn the
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
* this fuse. A fresh START or RESUME (`5`, speech resumed) re-arms the long fuse instead.
*/
private val WIND_DOWN_TIMEOUT = 6.seconds
/**
* For an *inferred* end only (the [STALE_TIMEOUT] backstop fired — we lost track of the
* conversation), a pause older than this no longer auto-resumes: surprise playback long after
* is worse than a stranded pause. An explicit terminal frame, or the short wind-down fuse,
* resumes regardless of age — those carry real/recent evidence the conversation just ended.
*/
private val PAUSE_RESUME_WINDOW = 2.minutes
/**
* A CA terminal landing within this window of an AAP ear-detection change is treated as a pod
* removal/insertion re-key artifact (firmware emits 8,9 then 1,2 around the physical change),
* not a real end. The artifact terminal and the ear change land within tens of ms of each other
* (measured ~30ms on Pro 3, either side depending on which pod); a real end is seconds away.
* Kept well under that gap so genuine terminals aren't deferred to the fuse. (The ear-detection
* 0x06 frames are emitted on removal even when the EarDetectionEnabled setting is off, so this
* suppression does not depend on that setting being enabled.)
*/
private val EAR_TRANSITION_WINDOW = 2.seconds
/**
* How long to wait on an *uncorroborated cold* terminal (no preceding wind-down HOLD, no ear
* change yet) before treating it as a real end. Covers the reverse frame-ordering on
* primary-pod removal, where the firmware sends the terminal tens of ms BEFORE the ear-detection
* frame (measured ~30ms on Pro 3) — too early for the backward-looking check. Only cold
* terminals settle; a normal end
* (`…3,0xB,4,8`) and an abort (`7,8`) are already corroborated by the fuse and resume instantly,
* so this adds no latency to genuine conversation ends.
*/
private val STOP_SETTLE_DELAY = 250.milliseconds
}
}
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -223,6 +224,8 @@ class PlayPause @Inject constructor(
rawDecision = confirmation.decision,
currentState = currState,
autoPauseEnabled = reactions.autoPause,
now = current.ble?.seenLastAt,
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
)
pendingPauseDebounce = debounceResult.pending
@@ -240,7 +243,7 @@ class PlayPause @Inject constructor(
"rawShouldPlay=${confirmation.decision.shouldPlay}"
}
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
"Pause debounce committed: source=$source confirmed pause"
"Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
}
PauseDebounceEvent.NONE -> {}
}
@@ -438,12 +441,21 @@ class PlayPause @Inject constructor(
}
/**
* Sample-count debounce for pause decisions when the ear-detection source is an
* unauthenticated BLE advertisement.
* Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
* is an unauthenticated BLE advertisement.
*
* RF interference can produce a single corrupt advert that decodes as not-worn,
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
* 3 consecutive not-worn samples before firing.
* RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
* false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
* samples before firing on the *count* path.
*
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
*
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
* — subsequent samples after the initial detection are not-worn → not-worn, and
@@ -462,6 +474,8 @@ class PlayPause @Inject constructor(
rawDecision: PlayPauseDecision,
currentState: EarDetectionState,
autoPauseEnabled: Boolean,
now: Instant? = null,
generatedAtNanos: Long? = null,
): PauseDebounceResult {
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
@@ -525,6 +539,8 @@ class PlayPause @Inject constructor(
profileId = profileId,
initialPodCount = currentState.podCount,
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
startedAt = now,
startedGeneratedAtNanos = generatedAtNanos,
),
event = PauseDebounceEvent.STARTED,
)
@@ -545,7 +561,10 @@ class PlayPause @Inject constructor(
shouldPause = false,
reason = "Debouncing pause (rebound tolerated)",
),
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
pending = activePending.copy(
resetTolerance = activePending.resetTolerance - 1,
reboundTolerated = true,
),
event = PauseDebounceEvent.ADVANCED,
)
}
@@ -554,12 +573,41 @@ class PlayPause @Inject constructor(
// Confirmation: count <= initialPodCount, decrement remaining.
val remaining = activePending.confirmationsRemaining - 1
if (remaining <= 0) {
val commitOnCount = remaining <= 0
// Time-cap early commit: once the not-worn condition has persisted at least
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
// the "≥2 consecutive, distinct not-worn receptions" invariant:
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
// path is disabled and we fall back to pure sample-count.
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
// broken/constant OEM timebase just disables the time path (fail-safe to count).
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
// only delay, never prematurely fire.
val startedAt = activePending.startedAt
val startedNanos = activePending.startedGeneratedAtNanos
val elapsed = if (startedAt != null && now != null) {
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
} else {
null
}
val commitOnTime = !activePending.reboundTolerated &&
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
startedNanos != null && generatedAtNanos != null &&
generatedAtNanos != startedNanos
if (commitOnCount || commitOnTime) {
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
return PauseDebounceResult(
decision = PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
reason = "Debounced pause confirmed via $mode " +
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
),
pending = null,
event = PauseDebounceEvent.COMMITTED,
@@ -674,6 +722,18 @@ class PlayPause @Inject constructor(
// shows a pod returning shouldn't kill the pending, since the next sample may
// confirm the pods are still out.
val resetTolerance: Int = 1,
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
// not-worn sample. null → the time-cap early commit is inactive for this pending.
val startedAt: Instant? = null,
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
// not-worn sample. The time-cap commit requires the confirming sample to be a
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
// same cached advert in a later batch cannot early-commit on one physical advert.
val startedGeneratedAtNanos: Long? = null,
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
// (the not-worn samples are no longer strictly consecutive), falling back to pure
// sample-count confirmation.
val reboundTolerated: Boolean = false,
)
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
@@ -801,5 +861,19 @@ class PlayPause @Inject constructor(
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
*/
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
/**
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
* not-worn condition has persisted this long (measured from the first not-worn sample's
* observation time) with at least one further *distinct* not-worn reception and no
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
*
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
* see no change (count commits first). Tunable.
*/
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
}
}
@@ -11,11 +11,13 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import java.time.Duration
@@ -32,10 +34,14 @@ class PopUpReaction @Inject constructor(
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDeviceByTier
.distinctUntilChangedBy {
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid).
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex)
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
// lid state. The latter is essential: caseLidState is recovered from history, so it can
// flip OPEN<->CLOSED while the *selected* frame's raw bytes stay identical (e.g. a steady
// out-of-case frame while a sibling in-case frame updates). Keying on rawDataHex alone
// would swallow that transition and the popup would miss its show/hide.
listOf(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex, it?.caseLidState)
}
.withPrevious()
.setupCommonEventHandlers(TAG) { "popUpCase" }
@@ -71,7 +77,7 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current)
}
private fun throttleCasePopUps(current: PodDevice): Event? {
internal fun throttleCasePopUps(current: PodDevice): Event? {
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
val now = timeSource.now()
val lastShown = caseCoolDowns[cooldownKey]
@@ -95,9 +101,9 @@ class PopUpReaction @Inject constructor(
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
// Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
// here would let a transient UNKNOWN (e.g. a brief out-of-case frame) suppress a
// genuine OPEN for the whole cooldown window. CLOSED still resets it above.
Event.PopupHide(now)
}
@@ -115,7 +121,7 @@ class PopUpReaction @Inject constructor(
private fun monitorConnection(): Flow<Event> = combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().distinctUntilChangedBy {
deviceMonitor.primaryDeviceByTier.distinctUntilChangedBy {
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
},
) { devices, broadcast ->
@@ -192,7 +198,48 @@ class PopUpReaction @Inject constructor(
}
.setupCommonEventHandlers(TAG) { "popUpConnection" }
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
/**
* Backstop for case-open popups that never receive a CLOSED frame because the device left BLE
* range while the lid was open — otherwise the overlay lingers until manually dismissed (one of
* the symptoms in #598). A ticker re-checks the primary device's freshness; once a previously
* fresh OPEN broadcast goes stale past [CASE_OPEN_STALE_TIMEOUT] (no newer advertisement, or the
* device dropped to cache-only), a single Hide is emitted. The lid-driven [monitorCase] still
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
*/
private fun monitorCaseStaleClose(): Flow<Event> = combine(
deviceMonitor.primaryDeviceByTier,
staleCheckTicker(),
) { device, _ -> isCaseOpenBroadcastFresh(device) }
.distinctUntilChanged()
.withPrevious()
.mapNotNull { (wasFresh, isFresh) ->
if (wasFresh == true && !isFresh) {
log(TAG) { "Case-open broadcast went stale, emitting Hide" }
Event.PopupHide(timeSource.now())
} else {
null
}
}
.setupCommonEventHandlers(TAG) { "popUpCaseStale" }
/** True while the primary device is eligible and currently advertising a fresh OPEN lid. */
internal fun isCaseOpenBroadcastFresh(device: PodDevice?): Boolean {
if (device?.reactions?.showPopUpOnCaseOpen != true) return false
if (device.caseLidState != DualApplePods.LidState.OPEN) return false
// Track BLE freshness specifically, not PodDevice.seenLastAt (which also counts AAP/cache):
// the lid is a BLE-only signal, so a live AAP socket must not keep a stale OPEN on screen.
val bleSeenLastAt = device.ble?.seenLastAt ?: return false
return Duration.between(bleSeenLastAt, timeSource.now()) <= CASE_OPEN_STALE_TIMEOUT
}
private fun staleCheckTicker(): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(STALE_CHECK_INTERVAL.toMillis())
}
}
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection(), monitorCaseStaleClose())
sealed class Event {
data class PopupShow(
@@ -296,5 +343,11 @@ class PopUpReaction @Inject constructor(
companion object {
private val TAG = logTag("Reaction", "PopUp")
/** A case-open popup is force-dismissed once its OPEN broadcast hasn't refreshed for this long. */
private val CASE_OPEN_STALE_TIMEOUT: Duration = Duration.ofSeconds(4)
/** How often the stale-close backstop re-evaluates freshness. */
private val STALE_CHECK_INTERVAL: Duration = Duration.ofSeconds(2)
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
@@ -22,14 +23,13 @@ import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
@@ -47,6 +47,9 @@ class TroubleShooterViewModel @Inject constructor(
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
/** Guards against re-entrant runs (e.g. a double tap on "Try again"). */
private val runLock = Mutex()
data class State(val bleState: BleState)
val state = _bleState.map { State(it) }.asLiveState()
@@ -82,169 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
}
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
if (!runLock.tryLock()) {
log(TAG, WARN) { "troubleShootBle() ignored, a run is already in progress" }
return@launch
}
log(TAG, INFO) { "troubleShootBle()" }
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
try {
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
try {
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
// The combo under which supported headphones were detected (set in sweep 2).
var supportedCombo: BlePodMonitor.CompatOverride? = null
// Set only when the run reaches a terminal success. Persisted on exit; staying null
// (any failure, or cancellation) means we just clear the override, which restores the
// user's original — never-touched — settings.
var comboToPersist: BlePodMonitor.CompatOverride? = null
try {
run {
progress("Checking for headphones...")
if (findLiveBleHeadphones()) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
blePodMonitor.setUnfilteredOverride(unfiltered)
val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
{ combo, unfiltered ->
progress(
"SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
"batchingDisabled=${combo.offloadedBatchingDisabled}, " +
"indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
)
blePodMonitor.setCompatOverride(combo)
blePodMonitor.setUnfilteredOverride(unfiltered)
val devices = collectFreshDevices()
log(TAG) { "SCAN: Fresh BLE devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
} else {
progress("SCAN: No data received")
}
devices
}
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
run {
progress("Checking if we can receive BLE data at all.")
val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
if (!gotData) {
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
return@override
}
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
doScan(combo, false).any { it !is UnknownSnapshotBle }
}
if (supportedCombo == null) {
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = collectFreshDevices()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxByOrNull { it.signalQuality }
if (candidate == null) {
failure(
"No supported headphones found near your device.",
BleState.Result.Failure.Type.HEADPHONES,
)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
try {
// Persist the winning combo before dropping the override so the effective scan
// settings stay equal with no restart flicker. Done in its own try so the
// override is still cleared (restoring originals) even if a write throws.
comboToPersist?.let { persistCompat(it) }
} finally {
blePodMonitor.setCompatOverride(null)
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = timeSource.elapsedRealtime()
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
}
} finally {
runLock.unlock()
}
}
/**
* Waits up to [STEP_TIME] for the primary profile to be backed by a *fresh, live BLE*
* observation. A cached-only / AAP-only primary does not count — the troubleshooter is about
* whether BLE advertisements are actually reaching us. The freshness cutoff (snapshot seen at or
* after this call) means it reflects the currently-active scan settings and doesn't rely on the
* device cache having been cleared beforehand.
*/
private suspend fun findLiveBleHeadphones(): Boolean {
val threshold = timeSource.now()
return withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().firstOrNull { device ->
device?.ble != null && (device.seenLastAt?.let { it >= threshold } == true)
} != null
} ?: false
}
/**
* Collects BLE devices observed *after the current scan settings take effect*. Option changes
* restart the scan after a throttle, and [BlePodMonitor] keeps a 20s device cache, so without a
* freshness cutoff a stale observation from a previous combo could be mistaken for a "win".
*/
private suspend fun collectFreshDevices(): List<BlePodSnapshot> {
// Drop anything cached under a previous combo so it can't satisfy this attempt.
blePodMonitor.clearDeviceCache()
val freshThreshold = timeSource.now().plusMillis(SCAN_SETTLE_MS)
val start = timeSource.elapsedRealtime()
val collected = mutableListOf<BlePodSnapshot>()
// Accumulate as we go: a timeout must not discard what we already observed. toList() only
// returns once the flow completes, which a quiet channel may never do within the window.
withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.collect { snapshots -> collected.addAll(snapshots) }
}
return collected
.filter { it.seenLastAt >= freshThreshold }
.distinctBy { it.address }
}
private fun persistCompat(combo: BlePodMonitor.CompatOverride) {
generalSettings.isOffloadedFilteringDisabled.valueBlocking = combo.offloadedFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = combo.offloadedBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = combo.indirectCallback
}
sealed class BleState {
class Intro : BleState()
@@ -295,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
companion object {
const val STEP_TIME = 10 * 1000L
/**
* Grace period after switching compat settings before an observation counts as "fresh".
* Covers [BlePodMonitor]'s ~1s scan-option throttle plus the scanner restart.
*/
const val SCAN_SETTLE_MS = 1500L
/**
* Compatibility combinations to probe, ordered fewest-disables-first so the first one that
* works (and gets persisted) is the *minimal* set of overrides — e.g. a phone that only
* needs batching disabled won't also get filtering disabled. Triple semantics:
* (offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).
*/
val COMPAT_COMBOS: List<BlePodMonitor.CompatOverride> = listOf(
BlePodMonitor.CompatOverride(false, false, false), // baseline (no overrides)
BlePodMonitor.CompatOverride(false, true, false), // batching only
BlePodMonitor.CompatOverride(true, false, false), // filtering only
BlePodMonitor.CompatOverride(false, false, true), // indirect callback only
BlePodMonitor.CompatOverride(false, true, true), // batching + indirect
BlePodMonitor.CompatOverride(true, false, true), // filtering + indirect
BlePodMonitor.CompatOverride(true, true, false), // filtering + batching
BlePodMonitor.CompatOverride(true, true, true), // everything
)
val TAG = logTag("TroubleShooter", "VM")
}
}
@@ -12,7 +12,7 @@
<clip>
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="?android:attr/colorAccent" />
<solid android:color="@color/brand_primary" />
</shape>
</clip>
</item>
@@ -24,8 +24,8 @@
android:id="@+id/pod_left_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_left" />
<ProgressBar
@@ -45,7 +45,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="95%" />
tools:text="95% · 5h 40m" />
<LinearLayout
android:layout_width="36dp"
@@ -58,8 +58,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -67,8 +67,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -88,8 +88,8 @@
android:id="@+id/pod_case_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_case" />
<ProgressBar
@@ -122,8 +122,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
@@ -142,8 +142,8 @@
android:id="@+id/pod_right_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/device_airpods_gen1_right" />
<ProgressBar
@@ -163,7 +163,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="92%" />
tools:text="92% · 5h 25m" />
<LinearLayout
android:layout_width="36dp"
@@ -176,8 +176,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -185,8 +185,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -38,6 +38,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -46,6 +48,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -76,6 +80,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
@@ -105,6 +111,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -113,6 +121,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -24,8 +24,8 @@
android:id="@+id/headphones_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/twotone_headphones_24" />
<TextView
@@ -64,15 +64,15 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="95%" />
tools:text="95% · 14h 30m" />
<ImageView
android:id="@+id/headphones_charging"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -80,8 +80,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tintMode="multiply"
android:tint="?android:attr/colorAccent"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -30,6 +30,8 @@
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon.Notification"
android:layout_marginStart="12dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
@@ -46,6 +48,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
@@ -54,6 +58,8 @@
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="2dp"
android:tint="@color/notification_icon_tint"
android:tintMode="src_in"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
+32 -2
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Wys verbindingsopspringer</string>
<string name="settings_popup_connected_description">Wys \'n opspringer wanneer die toestel vir die eerste keer koppel.</string>
<string name="settings_popup_info_not_in_app">Opspringvensters word slegs gewys as jy nie in die program is nie.</string>
<string name="settings_charged_notification_label">Gelaaide kennisgewing</string>
<string name="settings_charged_notification_description">Wys \'n kennisgewing sodra alles wat laai die teikenlaai vlak bereik.</string>
<string name="settings_charged_threshold_label">Teikenlaai vlak</string>
<string name="settings_charged_scope_label">Gemonitorde batterye</string>
<string name="settings_charged_scope_pods_label">Slegs pods</string>
<string name="settings_charged_scope_case_label">Slegs kis</string>
<string name="settings_charged_scope_both_label">Pods en kis</string>
<string name="device_settings_experimental_title">Eksperimentele kenmerk</string>
<string name="device_settings_experimental_description">Hierdie kenmerk is nog nie op alle toestelle getoets nie. As iets nie korrek werk nie, open asseblief \'n kwessie met \'n foutopsporingsloglêer.</string>
<string name="device_settings_experimental_action">Open Kwessiespoorder</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Ontfout</string>
<string name="settings_general_label">Algemene instellings</string>
<string name="settings_general_description">Algemene aanpassings wat die hele toepassing beïnvloed.</string>
<string name="settings_overview_hide_unmatched_label">Versteek onooreenstemmende toestelle</string>
<string name="settings_overview_hide_unmatched_description">Moenie naby toestelle wys wat nie ooreenstem met enige van jou profiele in die oorsig nie, bv. ander mense se AirPods.</string>
<string name="device_battery_estimate_toggle_label">Batteryskattings</string>
<string name="device_battery_estimate_card_desc">Leer hoe hierdie toestel laai en ontlaai om oorblywende tyd, laadtyd en batterygesondheid in te skat.</string>
<string name="device_battery_estimate_reset_action">Geleerde gegevens herstel</string>
<string name="device_battery_estimate_reset_desc">Begin opnuut van die gegradeerde batterylewensduur.</string>
<string name="device_battery_estimate_reset_confirm_title">Geleerde gegevens herstel?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod sal hierdie toestel se gemete afvoer, laadsnelheid en batterygesondheid vergeet en opnuut met die gegradeerde batterylewensduur begin.</string>
<string name="settings_acknowledgements_label">Erkennings</string>
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string>
<string name="widget_config_show_device_label">Wys toestelnaam</string>
<string name="widget_config_reset_label">Stel terug na standaarde</string>
<string name="widget_config_preview_resize_hint">U kan die widget vergroot of verklein nadat u dit op u tuisskerm geplaas het.</string>
<string name="widget_config_preview_resize_hint">Jy kan die widget se grootte aanpas nadat jy dit op jou tuisskerm geplaas het.</string>
<string name="widget_config_custom_label">Aangepas</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Donker</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is gedeaktiveer, aktiveer dit ;)</string>
<string name="overview_monitoring_active_label">Moniteer vir toestelle</string>
<string name="overview_monitoring_active_description">Maak seker jou toestel is naby en aktief.</string>
<string name="overview_troubleshoot_suggestion_label">Verbind, maar geen data nie</string>
<string name="overview_troubleshoot_suggestion_description">Jou toestel is verbind, maar CAPod ontvang geen lewende data daarvan nie. Jou foon het dalk \'n versoenbaarheidsopsie nodig — die probleemoplosser kan probeer om een outomaties te vind.</string>
<string name="overview_troubleshoot_suggestion_action">Voer probleemoplosser uit</string>
<string name="overview_unmatched_devices_label">Ongepasde toestelle</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d toestel sonder ooreenstemmende profiel</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Laas gesien: %s</string>
<string name="first_seen_x">Eerste keer gesien: %s</string>
<string name="battery_cached_label">Laas bekend · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Wys kennisgewings</string>
<string name="permission_post_notifications_description">"Laat CAPod toe om kennisgewings oor jou AirPods te wys, bv. hul huidige status terwyl hulle gekoppel is."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string>
<string name="device_settings_info_left_bonded_label">Links Gekoppel</string>
<string name="device_settings_info_right_bonded_label">Regs Gekoppel</string>
<string name="device_settings_info_battery_health_label">Batterygesondheid (beraamde)</string>
<string name="device_settings_info_battery_health_left_label">Linker Batterygesondheid (beraamde)</string>
<string name="device_settings_info_battery_health_right_label">Regter Batterygesondheid (beraamde)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Besig om te bepaal, kom later terug</string>
<string name="device_settings_info_details_label">Toestelbesonderhede</string>
<string name="device_settings_info_details_action">Wys toestelbesonderhede</string>
<string name="device_settings_info_status_label">Status</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Slaapopsporing</string>
<string name="reaction_sleep_notification_title">Gepauseer deur Slaapopsporing</string>
<string name="reaction_sleep_notification_text">%1$s het aangemeld dat jy aan die slaap geraak het, so jou musiek is gepauseer. Jy kan dit in die toestelinstellings deaktiveer.</string>
<string name="reaction_charged_channel_label">Gelaaide kennisgewing</string>
<string name="reaction_charged_notification_title">Laai voltooi</string>
<string name="reaction_charged_notification_text_full">%1$s is volledig gelaai.</string>
<string name="reaction_charged_notification_text_partial">%1$s is na %2$d%% gelaai.</string>
<string name="device_settings_rename_label">Hernoem</string>
<string name="device_settings_rename_hint">Toestelnaam</string>
<string name="device_settings_rename_confirm">Hernoem</string>
@@ -506,7 +536,7 @@
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
<string name="device_settings_connected_device_label">Toestel %d</string>
<string name="device_settings_connected_device_call">In \n oproep</string>
<string name="device_settings_connected_device_call">In n oproep</string>
<string name="device_settings_connected_device_media">Speel media</string>
<string name="device_settings_noise_control_label">Geraasbeheer</string>
<string name="device_settings_eq_label">Egaliseerder</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">የግንኙነት ብቅ-ባይ አሳይ</string>
<string name="settings_popup_connected_description">መሳሪያው ለመጀመሪያ ጊዜ ሲገናኝ ብቅ-ባይ አሳይ።</string>
<string name="settings_popup_info_not_in_app">ፖፕ-አፕ መልዕክቶች በፕሮግራሙ ውስጥ ሳታሉ ብቻ ይታያሉ።</string>
<string name="settings_charged_notification_label">የተሞላ ማሳወቂያ</string>
<string name="settings_charged_notification_description">ሁሉም ነገር ወደ ታለመው የኃይል ደረጃ ሲደርስ ማሳወቂያ አሳይ።</string>
<string name="settings_charged_threshold_label">ታለመው የኃይል ደረጃ</string>
<string name="settings_charged_scope_label">የሚታዩ ባትሪዎች</string>
<string name="settings_charged_scope_pods_label">ፖድስ ብቻ</string>
<string name="settings_charged_scope_case_label">ኬዝ ብቻ</string>
<string name="settings_charged_scope_both_label">ፖድስ እና ኬዝ</string>
<string name="device_settings_experimental_title">ሙከራዊ ባህሪ</string>
<string name="device_settings_experimental_description">ይህ ባህሪ በሁሉም መሳሪያዎች ላይ ገና አልተሞከረም። ሆኖም ካልሰራ፣ እባክዎ የዲበግ ምዝግብ ማስታወሻ ጨምረው ጉዳዩን ይክፈቱ።</string>
<string name="device_settings_experimental_action">የጉዳይ መከታተያ ክፈት</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">ዲባግ</string>
<string name="settings_general_label">ቅንብሮች</string>
<string name="settings_general_description">ሙሉውን መተግበሪያ የሚነኩ አጠቃላይ ማስተካከያዎች።</string>
<string name="settings_overview_hide_unmatched_label">ያልተዛመዱ መሳሪያዎችን ደብቅ</string>
<string name="settings_overview_hide_unmatched_description">ከፕሮፋይሎቻቸው ጋር የማይዛመዱ አቅራቢያ ያሉ መሳሪያዎችን በዝርዝሩ ውስጥ አታሳይ፣ ለምሳሌ የሌሎች ሰዎች AirPods።</string>
<string name="device_battery_estimate_toggle_label">ባትሪ ግምቶች</string>
<string name="device_battery_estimate_card_desc">ይህ መሳሪያ እንዴት ሲሞላ እና እንደሚወድ በመማር ተቀሩትን ጊዜ፣ ሞላ ሲሳ የሚቆይ ጊዜ እና ባትሪ ጤናን ግምት ማድረግ ይችላሉ።</string>
<string name="device_battery_estimate_reset_action">የተማረውን ዳታ ዳግም ያስጀምሩ</string>
<string name="device_battery_estimate_reset_desc">ተወሰነ ባትሪ ዕድሜ ሪጀምር።</string>
<string name="device_battery_estimate_reset_confirm_title">የተማረውን ዳታ ዳግም ያስጀምራሉ?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod ይህ መሳሪያ የተለካው ወደዳ፣ ሙሎት ፍጥነት እና ባትሪ ጤናን ይረሳል እና ተወሰነ ባትሪ ዕድሜ ሪጀምራል።</string>
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">የዳራ ግልጽነት</string>
<string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string>
<string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string>
<string name="widget_config_preview_resize_hint">ጄቱን በዋናው ስክሪን ላይ ስቀመጡ በኋላ መጠን መቀየር ይችላሉ።</string>
<string name="widget_config_preview_resize_hint">ጀትን በቤት ስክሪን ላይ ከአስቀመጡ በኋላ መጠንዋን መቀየር ይችላሉ።</string>
<string name="widget_config_custom_label">ብጁ</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">ጨለማ</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth ተሰናክሏል፣ ያንቁት ;)</string>
<string name="overview_monitoring_active_label">መሣሪያዎችን በመከታተል ላይ</string>
<string name="overview_monitoring_active_description">መሣሪያዎ ቅርብ እና ንቁ መሆኑን ያረጋግጡ።</string>
<string name="overview_troubleshoot_suggestion_label">ተገናኝቷል፣ ግን ምንም ዳታ የለም</string>
<string name="overview_troubleshoot_suggestion_description">መሳሪያህ ተገናኝቷል፣ ግን CAPod ከእሱ ምንም ቀጥታ ዳታ አይቀበልም። ስልክህ የተኳሃኝነት አማራጭ ሊፈልግ ይችላል — ችግር ፈቺው በራስ-ሰር ለማግኘት ሊሞክር ይችላል።</string>
<string name="overview_troubleshoot_suggestion_action">ችግር ፈቺን አስጀምር</string>
<string name="overview_unmatched_devices_label">ያልተዛመዱ መሣሪያዎች</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">ተዛማጅ መገለጫ የሌለው %d መሣሪያ</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">ለመጨረሻ ጊዜ የታየው: %s</string>
<string name="first_seen_x">ለመጀመሪያ ጊዜ የታየው: %s</string>
<string name="battery_cached_label">የመጨረሻ ታወቀ · %s</string>
<string name="battery_time_remaining_format_hm">%1$dሰ %2$ደ</string>
<string name="battery_time_remaining_format_h">%1$dሰ</string>
<string name="battery_time_remaining_format_m">%1$dደ</string>
<string name="permission_post_notifications_label">ማሳወቂያዎችን አሳይ</string>
<string name="permission_post_notifications_description">"CAPod ስለ AirPodsዎ ማሳወቂያዎችን እንዲያሳይ ፍቀድ፣ ለምሳሌ ሲገናኙ የአሁኑ ሁኔታቸውን።"</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string>
<string name="device_settings_info_left_bonded_label">ግራ ተያይዟል</string>
<string name="device_settings_info_right_bonded_label">ቀኝ ተያይዟል</string>
<string name="device_settings_info_battery_health_label">ባትሪ ጤና (ግምታዊ)</string>
<string name="device_settings_info_battery_health_left_label">ግራ ባትሪ ጤና (ግምታዊ)</string>
<string name="device_settings_info_battery_health_right_label">ቀኝ ባትሪ ጤና (ግምታዊ)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">ገና ይወሰናል፣ በኋላ ይመልከቱ</string>
<string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string>
<string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string>
<string name="device_settings_info_status_label">ሁኔታ</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">የእንቅልፍ ማወቂያ</string>
<string name="reaction_sleep_notification_title">በእንቅልፍ ማወቂያ ምክንያት ታግዷል</string>
<string name="reaction_sleep_notification_text">%1$s እርስዎ ተኝተዋል ብሎ ዘገበ፣ ስለዚህ ሙዚቃዎ ተቋርጧል። ይህን በመሣሪያ ቅንብሮች ውስጥ ማሰናከል ይችላሉ።</string>
<string name="reaction_charged_channel_label">የተሞላ ማሳወቂያ</string>
<string name="reaction_charged_notification_title">ኃይል መሙላት ተጠናቋል</string>
<string name="reaction_charged_notification_text_full">%1$s ሙሉ በሙሉ ተሞልቷል።</string>
<string name="reaction_charged_notification_text_partial">%1$s ወደ %2$d%% ተሞልቷል።</string>
<string name="device_settings_rename_label">ስም ቀይር</string>
<string name="device_settings_rename_hint">የመሣሪያ ስም</string>
<string name="device_settings_rename_confirm">ስም ቀይር</string>
+54 -24
View File
@@ -6,14 +6,14 @@
<string name="general_copy_action">نسخ</string>
<string name="general_thank_you_label">شكرًا لك</string>
<string name="general_upgrade_action">ترقية</string>
<string name="common_feature_requires_pro_msg">رقّي للفتح.</string>
<string name="common_feature_requires_pro_msg">رقِّ للفتح.</string>
<string name="common_upgrade_required_label">يتطلب الترقية</string>
<string name="general_donate_action">تبرع</string>
<string name="general_check_action">تحقق</string>
<string name="general_donate_action">تبرُّع</string>
<string name="general_check_action">فحص</string>
<string name="general_close_action">إغلاق</string>
<string name="general_edit_action">تعديل</string>
<string name="general_save_action">حفظ</string>
<string name="general_guide_action">دليل</string>
<string name="general_guide_action">الدليل</string>
<string name="general_continue_action">متابعة</string>
<string name="general_show_action">عرض</string>
<string name="general_hide_action">إخفاء</string>
@@ -22,18 +22,18 @@
<string name="upgrade_capod_description">احصل على ميزات إضافية وادعم المطور.</string>
<string name="upgrade_preamble">تم تطوير تطبيق CAPod بواسطة شخص واحد. يتيح التحديث ميزات إضافية ويساعد في استمرار عمل التطبيق.</string>
<string name="upgrade_benefit_themes">تخصيص السمة</string>
<string name="upgrade_benefit_autoplay">التشغيل التلقائي &amp; الإيقاف التلقائي</string>
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة &amp; الاتصال</string>
<string name="upgrade_benefit_autoplay">التشغيل التلقائي والإيقاف التلقائي</string>
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة والاتصال</string>
<string name="upgrade_benefit_widgets">عناصر الشاشة الرئيسة</string>
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدّمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء، &amp; السيقان، &amp; المزيد</string>
<string name="upgrade_benefit_support">دعم المطوّر</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المستخدَم.</string>
<string name="upgrade_screen_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء والسيقان والمزيد</string>
<string name="upgrade_benefit_support">ادعم المطور</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المُستخدَم.</string>
<string name="upgrade_screen_options_description">نفس الميزات، بتسعير مختلف. يشمل الاشتراك تجربة مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
<string name="upgrade_screen_subscription_action">الاشتراك سنويًّا</string>
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
<string name="upgrade_screen_iap_action">اشترِ مرة واحدة</string>
<string name="upgrade_screen_iap_action">الشراء مرة واحدة</string>
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
<string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string>
<string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
@@ -44,18 +44,18 @@
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopause_label">إيقاف مؤقّت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقّتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</string>
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجددًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي قمت بتشغيلها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقفة.</string>
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجدّدًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي شغّلتها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقّفة.</string>
<string name="settings_start_music_on_wear_label">تشغيل الموسيقى عند الارتداء</string>
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السماعات، حتى بعد الإيقاف اليدوي.</string>
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السمّاعات، حتى بعد الإيقاف اليدوي.</string>
<string name="settings_eardetection_info_label">ملاحظة اكتشاف الأذن</string>
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيد من Apple. يتم اكتشاف \"السماعة الأساسية\" فقط (المستخدمة للميكروفون). قم بالتكوين على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيدٌ من Apple. يتم اكتشاف \"السمّاعة الأساسية\" فقط (المستخدَمة للميكروفون). اضبط على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
<string name="settings_signal_minimum_label">الحد الأدنى لجودة الإشارة</string>
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتع بها الجهاز حتى يعتبر ملكك.</string>
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتّع بها الجهاز حتى يُعدّ ملكك.</string>
<string name="settings_autoconnect_label">اتصال تلقائي</string>
<string name="settings_autoconnect_description">إذا لم يتصل Android تلقائيًا، فيمكننا أن نطلب منه ذلك أيضًا. سيؤدي هذا إلى ضبط إعداد وضع الشاشة على \"دائمًا\".</string>
<string name="settings_autoconnect_description">إذا لم يتّصل Android تلقائيًا، فيمكننا أن نطلب منه ذلك أيضًا. سيؤدي هذا إلى ضبط إعداد وضع الشاشة على \"دائمًا\".</string>
<string name="settings_autoconnect_info_android12">يعتمد الاتصال التلقائي على ميزة نظام يقيّدها Android 12 والإصدارات الأحدث. قد لا تعمل على جهازك.</string>
<string name="settings_autoconnect_condition_label">شرط الاتصال التلقائي</string>
<string name="settings_autoconnect_condition_description">متى يجب أن نحاول الاتصال بجهازك؟</string>
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">إظهار نافذة منبثقة للاتصال</string>
<string name="settings_popup_connected_description">إظهار نافذة منبثقة عند اتصال الجهاز لأول مرة.</string>
<string name="settings_popup_info_not_in_app">تظهر النوافذ المنبثقة فقط عندما لا تكون داخل التطبيق.</string>
<string name="settings_charged_notification_label">إشعار الشحن الكامل</string>
<string name="settings_charged_notification_description">اعرض إشعارًا بمجرد وصول جميع الأجهزة التي يتم شحنها إلى مستوى الشحن المستهدف.</string>
<string name="settings_charged_threshold_label">مستوى الشحن المستهدف</string>
<string name="settings_charged_scope_label">البطاريات المُراقبة</string>
<string name="settings_charged_scope_pods_label">السمّاعات فقط</string>
<string name="settings_charged_scope_case_label">الحافظة فقط</string>
<string name="settings_charged_scope_both_label">السماعات والحافظة</string>
<string name="device_settings_experimental_title">ميزة تجريبية</string>
<string name="device_settings_experimental_description">لم يتم اختبار هذه الميزة على جميع الأجهزة بعد. إذا كان هناك شيء لا يعمل بشكل صحيح، يرجى فتح تقرير مشكلة مع سجل تصحيح.</string>
<string name="device_settings_experimental_action">فتح متتبع المشكلات</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">تصحيح الأخطاء</string>
<string name="settings_general_label">الإعدادات</string>
<string name="settings_general_description">تعديلات عامة تؤثر على التطبيق بأكمله.</string>
<string name="settings_overview_hide_unmatched_label">إخفاء الأجهزة غير المتطابقة</string>
<string name="settings_overview_hide_unmatched_description">لا تعرض الأجهزة القريبة التي لا تتطابق مع أي من ملفات تعريفك في النظرة العامة، على سبيل المثال سمّاعات AirPods الخاصة بالآخرين.</string>
<string name="device_battery_estimate_toggle_label">تقديرات البطارية</string>
<string name="device_battery_estimate_card_desc">تعرّف على كيفية شحن هذا الجهاز وتفريغه لتقدير الوقت المتبقي، والوقت اللازم للشحن، وحالة البطارية.</string>
<string name="device_battery_estimate_reset_action">إعادة ضبط البيانات المُكتسَبة</string>
<string name="device_battery_estimate_reset_desc">البدء من جديد بناءً على عمر البطارية المُقدَّر.</string>
<string name="device_battery_estimate_reset_confirm_title">هل ترغب في إعادة ضبط البيانات المُكتسَبة؟</string>
<string name="device_battery_estimate_reset_confirm_message">سيتجاهل تطبيق CAPod معدل استهلاك البطارية المقاس لهذا الجهاز، وسرعة الشحن، وحالة البطارية، وسيبدأ من جديد بناءً على عمر البطارية المُقدَّر.</string>
<string name="settings_acknowledgements_label">شكر وتقدير</string>
<string name="settings_debug_autoreports_label">تقارير الأخطاء التلقائية</string>
<string name="settings_debug_autoreports_description">الإبلاغ تلقائيًا عن المشكلات، على سبيل المثال تفاصيل حول تعطل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
@@ -127,14 +142,14 @@
<string name="anc_widget_config_aap_required_hint">يتطلب هذا الودجت اتصالاً مباشراً (AAP) بجهازك. تأكد من إقران جهازك وتوصيله في التطبيق قبل استخدام هذا الودجت.</string>
<string name="anc_widget_no_anc_support_label">هذا الجهاز لا يدعم التحكم في الضوضاء</string>
<string name="anc_widget_aap_not_connected_label">غير متصل</string>
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متصل</string>
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متّصل</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">غير قريب</string>
<string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string>
<string name="tile_anc_label">التحكم في الضوضاء</string>
<string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string>
<string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string>
<string name="tile_anc_subtitle_no_anc_support">لا يوجد تحكم في الضوضاء</string>
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مُغلق</string>
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مغلق</string>
<string name="widget_config_screen_title">إعداد عنصر الواجهة</string>
<string name="widget_configuration_title">اختيار الجهاز</string>
<string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">شفافية الخلفية</string>
<string name="widget_config_show_device_label">إظهار اسم الجهاز</string>
<string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string>
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسية.</string>
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسة.</string>
<string name="widget_config_custom_label">مخصص</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">مظلم</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">البلوتوث معطل، قم بتمكينه ;)</string>
<string name="overview_monitoring_active_label">جارٍ مراقبة الأجهزة</string>
<string name="overview_monitoring_active_description">تأكد من أن جهازك قريب ونشط.</string>
<string name="overview_troubleshoot_suggestion_label">متصل، ولكن لا توجد بيانات</string>
<string name="overview_troubleshoot_suggestion_description">جهازك متصل، لكن تطبيق CAPod لا يستقبل أي بيانات مباشرة منه. قد يحتاج هاتفك إلى خيار توافق - يمكن لمُستكشف الأخطاء ومُصلحها محاولة إيجاد واحد تلقائيًّا.</string>
<string name="overview_troubleshoot_suggestion_action">تشغيل مُستكشف الأخطاء ومُصلحها</string>
<string name="overview_unmatched_devices_label">أجهزة غير متطابقة</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="zero">%d أجهزة بدون ملف تعريف مطابق</item>
@@ -274,6 +292,9 @@
<string name="last_seen_x">آخر ظهور: %s</string>
<string name="first_seen_x">أول ظهور: %s</string>
<string name="battery_cached_label">آخر معروف · %s</string>
<string name="battery_time_remaining_format_hm">%1$dس و%2$dد</string>
<string name="battery_time_remaining_format_h">%1$dس</string>
<string name="battery_time_remaining_format_m">%1$dد</string>
<string name="permission_post_notifications_label">إظهار الإشعارات</string>
<string name="permission_post_notifications_description">"السماح لـ CAPod بعرض إشعارات حول AirPods، مثل حالتها الحالية أثناء الاتصال."</string>
<!-- Device profiles -->
@@ -431,6 +452,11 @@
<string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string>
<string name="device_settings_info_left_bonded_label">اليسار مقترن</string>
<string name="device_settings_info_right_bonded_label">اليمين مقترن</string>
<string name="device_settings_info_battery_health_label">صحة البطارية (مُقدَّرة)</string>
<string name="device_settings_info_battery_health_left_label">صحة البطارية اليسرى (مُقدَّرة)</string>
<string name="device_settings_info_battery_health_right_label">صحة البطارية اليمنى (مُقدَّرة)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">لا يزال الأمر قيد التحديد، راجع لاحقًا</string>
<string name="device_settings_info_details_label">تفاصيل الجهاز</string>
<string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string>
<string name="device_settings_info_status_label">الحالة</string>
@@ -488,7 +514,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_label">ميكروفون</string>
<string name="device_settings_microphone_mode_description">سماعة الأذن المستخدمة كميكروفون</string>
<string name="device_settings_microphone_mode_description">سمّاعة الأذن المستخدمة كميكروفون</string>
<string name="device_settings_microphone_mode_auto">تلقائي</string>
<string name="device_settings_microphone_mode_right">يمين</string>
<string name="device_settings_microphone_mode_left">يسار</string>
@@ -510,6 +536,10 @@
<string name="reaction_sleep_channel_label">اكتشاف النوم</string>
<string name="reaction_sleep_notification_title">تم الإيقاف المؤقت بواسطة اكتشاف النوم</string>
<string name="reaction_sleep_notification_text">%1$s أفاد بأنك نمت، لذا تم إيقاف موسيقاك مؤقتًا. يمكنك تعطيل هذا في إعدادات الجهاز.</string>
<string name="reaction_charged_channel_label">إشعار الشحن الكامل</string>
<string name="reaction_charged_notification_title">اكتمل الشحن</string>
<string name="reaction_charged_notification_text_full">%1$s مشحون بالكامل.</string>
<string name="reaction_charged_notification_text_partial">تم شحن %1$s إلى %2$d%%.</string>
<string name="device_settings_rename_label">إعادة تسمية</string>
<string name="device_settings_rename_hint">اسم الجهاز</string>
<string name="device_settings_rename_confirm">إعادة تسمية</string>
+32 -2
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Qoşulma pop-up-ını göstər</string>
<string name="settings_popup_connected_description">Cihaz ilk dəfə qoşulduqda pop-up göstər.</string>
<string name="settings_popup_info_not_in_app">Açılan pəncərələr yalnız tətbiqdə olmadığınız zaman göstərilir.</string>
<string name="settings_charged_notification_label">Şarj bildirişi</string>
<string name="settings_charged_notification_description">Şarj olunan hər şey hədəf şarj səviyyəsinə çatdıqda bildiriş göndərin.</string>
<string name="settings_charged_threshold_label">Hədəf şarj səviyyəsi</string>
<string name="settings_charged_scope_label">İzlənilən akkumulyatorlar</string>
<string name="settings_charged_scope_pods_label">Yalnız qulaqcıqlar</string>
<string name="settings_charged_scope_case_label">Yalnız qutu</string>
<string name="settings_charged_scope_both_label">Qulaqcıqlar və qutu</string>
<string name="device_settings_experimental_title">Eksperimental funksiya</string>
<string name="device_settings_experimental_description">Bu funksiya hələ bütün cihazlarda sınaqdan keçirilməyib. Bir şey düzgün işləmirsə, debug loqu ilə problem bildirin.</string>
<string name="device_settings_experimental_action">Problem İzləyicisini Aç</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Sazlama</string>
<string name="settings_general_label">Parametrlər</string>
<string name="settings_general_description">Bütün tətbiqə təsir edən ümumi düzəlişlər.</string>
<string name="settings_overview_hide_unmatched_label">Uyğun olmayan cihazları gizlət</string>
<string name="settings_overview_hide_unmatched_description">İcmalda profillərinizə uyğun gəlməyən yaxın cihazları göstərməyin, məs. başqalarının AirPods-ları.</string>
<string name="device_battery_estimate_toggle_label">Batareya qiymətləndirmələri</string>
<string name="device_battery_estimate_card_desc">Bu cihazın necə doldurulduğunu və enerji istifadə etdiyini öyrənərək, qalan vaxtı, dolğunlaşma vaxtını və batareya sağlamlığını qiymətləndirin.</string>
<string name="device_battery_estimate_reset_action">Öyrənilmiş məlumatları sıfırla</string>
<string name="device_battery_estimate_reset_desc">Qiymətləndirilmiş batareya ömrünə qayıdıb yenidən başlayın.</string>
<string name="device_battery_estimate_reset_confirm_title">Öyrənilmiş məlumatları sıfırla?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod bu cihazın ölçülmüş enerji istifadəsini, dolğunlaşma sürətini və batareya sağlamlığını unudacaq, qiymətləndirilmiş batareya ömrünə qayıdıb yenidən başlayacaq.</string>
<string name="settings_acknowledgements_label">Təşəkkürlər</string>
<string name="settings_debug_autoreports_label">Avtomatik səhv hesabatları</string>
<string name="settings_debug_autoreports_description">Problemləri avtomatik olaraq bildirir, məsələn, tətbiqin çökməsi ilə bağlı təfərrüatları, beləliklə onu necə düzəldəcəyimi anlaya bilim.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Fon şəffaflığı</string>
<string name="widget_config_show_device_label">Cihaz adını göstər</string>
<string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string>
<string name="widget_config_preview_resize_hint">Vidceti ana ekranınıza yerləşdirdikdən sonra ölçüsünü dəyişə bilərsiniz.</string>
<string name="widget_config_preview_resize_hint">Vidcetin ana ekrana qoyduqdan sonra ölçüsünü dəyişə bilərsiniz.</string>
<string name="widget_config_custom_label">Fərdi</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Tünd</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth deaktivdir, onu aktivləşdirin ;)</string>
<string name="overview_monitoring_active_label">Cihazlar axtarılır</string>
<string name="overview_monitoring_active_description">Cihazınızın yaxında və aktiv olduğundan əmin olun.</string>
<string name="overview_troubleshoot_suggestion_label">Qoşuldu, lakin məlumat yoxdur</string>
<string name="overview_troubleshoot_suggestion_description">Cihazınız qoşulub, lakin CAPod ondan canlı məlumat almır. Telefonunuz uyğunluq seçiminə ehtiyac duya bilər — problem aradan qaldırıcı birini avtomatik tapmağa çalışa bilər.</string>
<string name="overview_troubleshoot_suggestion_action">Problem aradan qaldırıcını işə sal</string>
<string name="overview_unmatched_devices_label">Uyğunlaşdırılmamış cihazlar</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">Uyğun profili olmayan %d cihaz</item>
@@ -223,7 +241,7 @@
<string name="pods_dual_left_label">Sol qulaqlıq</string>
<string name="pods_dual_right_label">Sağ qulaqlıq</string>
<string name="pods_dual_left_short_label">S</string>
<string name="pods_dual_right_short_label">Sğ</string>
<string name="pods_dual_right_short_label">S</string>
<string name="pods_case_label">Qutu</string>
<string name="battery_unavailable_label">Batareya məlumatı yoxdur</string>
<string name="pods_case_status_open_label">Açıq</string>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Son görülmə: %s</string>
<string name="first_seen_x">İlk görülmə: %s</string>
<string name="battery_cached_label">Son məlum · %s</string>
<string name="battery_time_remaining_format_hm">%1$dsa %2$ddəq</string>
<string name="battery_time_remaining_format_h">%1$dsa</string>
<string name="battery_time_remaining_format_m">%1$ddəq</string>
<string name="permission_post_notifications_label">Bildirişlər göstər</string>
<string name="permission_post_notifications_description">"CAPod-a AirPods haqqında bildirişlər göstərməyə icazə verin, məsələn, qoşulduqda onların cari statusu."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string>
<string name="device_settings_info_left_bonded_label">Sol cütləşdirilmiş</string>
<string name="device_settings_info_right_bonded_label">Sağ cütləşdirilmiş</string>
<string name="device_settings_info_battery_health_label">Batareya Sağlığı (təxmini)</string>
<string name="device_settings_info_battery_health_left_label">Sol Batareya Sağlığı (təx.)</string>
<string name="device_settings_info_battery_health_right_label">Sağ Batareya Sağlığı (təx.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Hələ müəyyən edilir, sonra yoxlayın</string>
<string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string>
<string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string>
<string name="device_settings_info_status_label">Vəziyyət</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Yuxu Aşkarlaması</string>
<string name="reaction_sleep_notification_title">Yuxu Aşkarlaması ilə dayandırıldı</string>
<string name="reaction_sleep_notification_text">%1$s sizin yuxuya getdiyinizi bildirdi, buna görə musiqiniz dayandırıldı. Bunu cihaz parametrlərindən söndürə bilərsiniz.</string>
<string name="reaction_charged_channel_label">Şarj Bildirişi</string>
<string name="reaction_charged_notification_title">Şarj tamamlandı</string>
<string name="reaction_charged_notification_text_full">%1$s tam şarj olundu.</string>
<string name="reaction_charged_notification_text_partial">%1$s %2$d%% şarj olundu.</string>
<string name="device_settings_rename_label">Adı dəyişdirin</string>
<string name="device_settings_rename_hint">Cihaz adı</string>
<string name="device_settings_rename_confirm">Adı dəyişdirin</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Паказаць усплывальнае акно падключэння</string>
<string name="settings_popup_connected_description">Паказваць усплывальнае акно пры першым падключэнні прылады.</string>
<string name="settings_popup_info_not_in_app">Усплывальныя вокны паказваюцца толькі тады, калі вы не знаходзіцеся ў прыкладанні.</string>
<string name="settings_charged_notification_label">Апавяшчэнне аб зарадцы</string>
<string name="settings_charged_notification_description">Паказваць апавяшчэнне, калі ўсё, што зараджаецца, дасягне мэтавага ўзроўню зарадкі.</string>
<string name="settings_charged_threshold_label">Мэтавы ўзровень зарадкі</string>
<string name="settings_charged_scope_label">Адсочваемыя батарэі</string>
<string name="settings_charged_scope_pods_label">Толькі навушнікі</string>
<string name="settings_charged_scope_case_label">Толькі футарал</string>
<string name="settings_charged_scope_both_label">Навушнікі і футарал</string>
<string name="device_settings_experimental_title">Эксперыментальная функцыя</string>
<string name="device_settings_experimental_description">Гэта функцыя яшчэ не была праверана на ўсіх прыладах. Калі нешта не працуе правільна, калі ласка, стварыце запыт з журналам адладкі.</string>
<string name="device_settings_experimental_action">Адкрыць трэкер задач</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Адладка</string>
<string name="settings_general_label">Налады</string>
<string name="settings_general_description">Агульныя налады, якія ўплываюць на ўсю праграму.</string>
<string name="settings_overview_hide_unmatched_label">Хаваць непрызнаныя прылады</string>
<string name="settings_overview_hide_unmatched_description">Не паказваць у агляднічку блізкія прылады, якія не адпавядаюць ніводнаму з вашых профіляў, напр. AirPods іншых людзей.</string>
<string name="device_battery_estimate_toggle_label">Ацэнкі стану батарэі</string>
<string name="device_battery_estimate_card_desc">Даведайцеся, як гэтая прылада зараджаецца і разраджаецца, каб ацаніць час, які застаўся, час да поўнай зарадкі і здароўе батарэі.</string>
<string name="device_battery_estimate_reset_action">Скінуць вывучаныя даныя</string>
<string name="device_battery_estimate_reset_desc">Пачаць спачатку з намінальнага рэсурсу батарэі.</string>
<string name="device_battery_estimate_reset_confirm_title">Скінуць вывучаныя даныя?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod забудзе вымераныя для гэтай прылады хуткасць разраджэння, хуткасць зарадкі і здароўе батарэі, і пачне нанова з намінальнага рэсурсу батарэі.</string>
<string name="settings_acknowledgements_label">Падзякі</string>
<string name="settings_debug_autoreports_label">Аўтаматычныя справаздачы пра памылкі</string>
<string name="settings_debug_autoreports_description">Аўтаматычна паведамляе пра праблемы, напрыклад, звесткі пра збой праграмы, каб я мог зразумець, як гэта выправіць.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Прозрачнасць фона</string>
<string name="widget_config_show_device_label">Паказаць назву прылады</string>
<string name="widget_config_reset_label">Скінуць да змаўчання</string>
<string name="widget_config_preview_resize_hint">Вы можаце змяніць памер віджэта пасля размяшчэння яго на галоўным экране.</string>
<string name="widget_config_preview_resize_hint">Пасля размяшчэння віджэта на галоўным экране вы можаце змяніць яго памер.</string>
<string name="widget_config_custom_label">Адвольны</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Цёмны</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth адключаны, уключыце яго ;)</string>
<string name="overview_monitoring_active_label">Маніторынг прылад</string>
<string name="overview_monitoring_active_description">Пераканайцеся, што ваша прылада побач і актыўная.</string>
<string name="overview_troubleshoot_suggestion_label">Падлучана, але дадзеных няма</string>
<string name="overview_troubleshoot_suggestion_description">Ваша прылада падлучана, але CAPod не атрымлівае ніякіх жывых дадзеных з яе. Вашаму тэлефону можа спатрэбіцца параметр сумяшчальнасці — ліквідатар непаладак можа паспрабаваць знайсці яго аўтаматычна.</string>
<string name="overview_troubleshoot_suggestion_action">Запусціць ліквідатар непаладак</string>
<string name="overview_unmatched_devices_label">Неадпаведныя прылады</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d прылада без адпаведнага профілю</item>
@@ -270,6 +288,9 @@
<string name="last_seen_x">Апошні раз бачана: %s</string>
<string name="first_seen_x">Упершыню бачана: %s</string>
<string name="battery_cached_label">Апошняе вядомае · %s</string>
<string name="battery_time_remaining_format_hm">%1$dг %2$dхв</string>
<string name="battery_time_remaining_format_h">%1$dг</string>
<string name="battery_time_remaining_format_m">%1$dхв</string>
<string name="permission_post_notifications_label">Паказваць апавяшчэнні</string>
<string name="permission_post_notifications_description">"Дазвольце CAPod паказваць апавяшчэнні пра вашы AirPods, напрыклад іх бягучы стан пры падключэнні."</string>
<!-- Device profiles -->
@@ -421,6 +442,11 @@
<string name="device_settings_info_right_serial_label">Сэрыйны нумар правага навушніка</string>
<string name="device_settings_info_left_bonded_label">Левы спалучаны</string>
<string name="device_settings_info_right_bonded_label">Правы спалучаны</string>
<string name="device_settings_info_battery_health_label">Здароўе батарэі (ацэнка)</string>
<string name="device_settings_info_battery_health_left_label">Здароўе левай батарэі (ацэнка)</string>
<string name="device_settings_info_battery_health_right_label">Здароўе правай батарэі (ацэнка)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Яшчэ вызначаецца, зайдзіце пазней</string>
<string name="device_settings_info_details_label">Дэталі прылады</string>
<string name="device_settings_info_details_action">Паказаць дэталі прылады</string>
<string name="device_settings_info_status_label">Стан</string>
@@ -500,6 +526,10 @@
<string name="reaction_sleep_channel_label">Выяўленне сну</string>
<string name="reaction_sleep_notification_title">Прыпынена функцыяй выяўлення сну</string>
<string name="reaction_sleep_notification_text">%1$s паведаміла, што вы заснулі, таму музыка была прыпынена. Вы можаце адключыць гэта ў наладах прылады.</string>
<string name="reaction_charged_channel_label">Апавяшчэнне аб зарадцы</string>
<string name="reaction_charged_notification_title">Зарадка завершана</string>
<string name="reaction_charged_notification_text_full">%1$s поўнасцю зараджаны.</string>
<string name="reaction_charged_notification_text_partial">%1$s зараджаны да %2$d%%.</string>
<string name="device_settings_rename_label">Перайменаваць</string>
<string name="device_settings_rename_hint">Назва прылады</string>
<string name="device_settings_rename_confirm">Перайменаваць</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Показване на изскачащ прозорец за връзка</string>
<string name="settings_popup_connected_description">Показване на изскачащ прозорец, когато устройството се свърже за първи път.</string>
<string name="settings_popup_info_not_in_app">Изскачащите прозорци се показват само когато не сте в приложението.</string>
<string name="settings_charged_notification_label">Известие за зареждане</string>
<string name="settings_charged_notification_description">Показва известие, когато всичко, което се зарежда, достигне желаното ниво на заряд.</string>
<string name="settings_charged_threshold_label">Целево ниво на заряд</string>
<string name="settings_charged_scope_label">Наблюдавани батерии</string>
<string name="settings_charged_scope_pods_label">Само слушалки</string>
<string name="settings_charged_scope_case_label">Само калъф</string>
<string name="settings_charged_scope_both_label">Слушалки и калъф</string>
<string name="device_settings_experimental_title">Експериментална функция</string>
<string name="device_settings_experimental_description">Тази функция все още не е тествана на всички устройства. Ако нещо не работи правилно, моля отворете проблем с дневник за отстраняване на грешки.</string>
<string name="device_settings_experimental_action">Отвори проследявач на проблеми</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Отстраняване на грешки</string>
<string name="settings_general_label">Настройки</string>
<string name="settings_general_description">Общи настройки, които засягат цялото приложение.</string>
<string name="settings_overview_hide_unmatched_label">Скриване на несъответстващи устройства</string>
<string name="settings_overview_hide_unmatched_description">Не показвай намиращи се наблизо устройства, които не съответстват на нито един от профилите ти в прегледа, напр. AirPods на други хора.</string>
<string name="device_battery_estimate_toggle_label">Оценки на батерията</string>
<string name="device_battery_estimate_card_desc">Научете как това устройство се зарежда и разходва, за да оцените оставащото време, времето до зареждане и състоянието на батерията.</string>
<string name="device_battery_estimate_reset_action">Нулирай научените данни</string>
<string name="device_battery_estimate_reset_desc">Начало отново от номиналния живот на батерията.</string>
<string name="device_battery_estimate_reset_confirm_title">Нулирай научените данни?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod ще забрави измереното потребление, скоростта на зареждане и състоянието на батерията на това устройство и ще начне отново от номиналния живот на батерията.</string>
<string name="settings_acknowledgements_label">Благодарности</string>
<string name="settings_debug_autoreports_label">Автоматични доклади за грешки</string>
<string name="settings_debug_autoreports_description">Автоматично докладва проблеми, напр. подробности за срив на приложението, за да мога да разбера как да го поправя.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Прозрачност на фона</string>
<string name="widget_config_show_device_label">Показване на името на устройството</string>
<string name="widget_config_reset_label">Възстановяване на стандартните настройки</string>
<string name="widget_config_preview_resize_hint">Можете да преоразмерите джаджата, след като я поставите на началния си екран.</string>
<string name="widget_config_preview_resize_hint">Можете да преоразмеряте виджета след поставянето му на начален екран.</string>
<string name="widget_config_custom_label">Персонализирано</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Тъмно</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth е деактивиран, активирайте го ;)</string>
<string name="overview_monitoring_active_label">Наблюдение на устройства</string>
<string name="overview_monitoring_active_description">Уверете се, че устройството ви е наблизо и активно.</string>
<string name="overview_troubleshoot_suggestion_label">Свързан, но без данни</string>
<string name="overview_troubleshoot_suggestion_description">Устройството ти е свързано, но CAPod не получава данни от него в реално време. Телефонът ти може да се нуждае от опция за съвместимост — инструментът за отстраняване на неизправности може да се опита да намери такава автоматично.</string>
<string name="overview_troubleshoot_suggestion_action">Стартирай отстраняване на неизправности</string>
<string name="overview_unmatched_devices_label">Несъвпадащи устройства</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d устройство без съвпадащ профил</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Последно видяно: %s</string>
<string name="first_seen_x">Първо видяно: %s</string>
<string name="battery_cached_label">Последно известно · %s</string>
<string name="battery_time_remaining_format_hm">%1$dч %2$dм</string>
<string name="battery_time_remaining_format_h">%1$dч</string>
<string name="battery_time_remaining_format_m">%1$dм</string>
<string name="permission_post_notifications_label">Показване на известия</string>
<string name="permission_post_notifications_description">"Позволете на CAPod да показва известия за вашите AirPods, напр. текущото им състояние, докато са свързани."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Сериен номер на десния под</string>
<string name="device_settings_info_left_bonded_label">Ляво сдвоено</string>
<string name="device_settings_info_right_bonded_label">Дясно сдвоено</string>
<string name="device_settings_info_battery_health_label">Здравословно състояние на батерията (прогноза)</string>
<string name="device_settings_info_battery_health_left_label">Здравословно състояние на лявата батерия (прогноза)</string>
<string name="device_settings_info_battery_health_right_label">Здравословно състояние на дясната батерия (прогноза)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Все още се определя, проверете отново по-късно</string>
<string name="device_settings_info_details_label">Подробности за устройството</string>
<string name="device_settings_info_details_action">Покажи подробности за устройството</string>
<string name="device_settings_info_status_label">Статус</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Откриване на сън</string>
<string name="reaction_sleep_notification_title">Поставено на пауза от Откриване на сън</string>
<string name="reaction_sleep_notification_text">%1$s откри, че сте заспали, затова музиката ви беше поставена на пауза. Можете да деактивирате това в настройките на устройството.</string>
<string name="reaction_charged_channel_label">Известие за зареждане</string>
<string name="reaction_charged_notification_title">Зареждането е завършено</string>
<string name="reaction_charged_notification_text_full">%1$s е напълно зареден.</string>
<string name="reaction_charged_notification_text_partial">%1$s е зареден до %2$d%%.</string>
<string name="device_settings_rename_label">Преименуване</string>
<string name="device_settings_rename_hint">Наименование на устройството</string>
<string name="device_settings_rename_confirm">Преименуване</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">সংযোগ পপআপ দেখান</string>
<string name="settings_popup_connected_description">ডিভাইস প্রথমবার সংযুক্ত হলে একটি পপআপ দেখান।</string>
<string name="settings_popup_info_not_in_app">পপআপগুলি শুধুমাত্র তখনই দেখানো হয় যখন আপনি অ্যাপে থাকেন না।</string>
<string name="settings_charged_notification_label">চার্জ সম্পন্ন বিজ্ঞপ্তি</string>
<string name="settings_charged_notification_description">চার্জ হচ্ছে এমন সবকিছু লক্ষ্যমাত্রা চার্জ স্তরে পৌঁছালে একটি বিজ্ঞপ্তি দেখান।</string>
<string name="settings_charged_threshold_label">লক্ষ্যমাত্রা চার্জ স্তর</string>
<string name="settings_charged_scope_label">পর্যবেক্ষণ করা ব্যাটারি</string>
<string name="settings_charged_scope_pods_label">শুধুমাত্র পড</string>
<string name="settings_charged_scope_case_label">শুধুমাত্র কেস</string>
<string name="settings_charged_scope_both_label">পড এবং কেস</string>
<string name="device_settings_experimental_title">পরীক্ষামূলক ফিচার</string>
<string name="device_settings_experimental_description">এই ফিচারটি এখনো সব ডিভাইসে পরীক্ষা করা হয়নি। যদি কিছু সঠিকভাবে কাজ না করে, দয়া করে একটি ডিবাগ লগ সহ একটি ইস্যু খুলুন।</string>
<string name="device_settings_experimental_action">ইস্যু ট্র্যাকার খুলুন</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">ডিবাগ</string>
<string name="settings_general_label">সেটিংস</string>
<string name="settings_general_description">পুরো অ্যাপকে প্রভাবিত করে এমন সাধারণ পরিবর্তন।</string>
<string name="settings_overview_hide_unmatched_label">অমিল ডিভাইস লুকান</string>
<string name="settings_overview_hide_unmatched_description">ওভারভিউতে কাছের এমন ডিভাইস দেখাবে না যেগুলো আপনার কোনো প্রোফাইলের সাথে মেলে না, যেমন অন্যদের AirPods।</string>
<string name="device_battery_estimate_toggle_label">ব্যাটারি অনুমান</string>
<string name="device_battery_estimate_card_desc">এই ডিভাইসটি কীভাবে চার্জ হয় এবং কীভাবে খরচ হয় তা শিখুন যাতে অবশিষ্ট সময়, চার্জ হওয়ার সময় এবং ব্যাটারি স্বাস্থ্য অনুমান করতে পারেন।</string>
<string name="device_battery_estimate_reset_action">শেখা ডেটা রিসেট করুন</string>
<string name="device_battery_estimate_reset_desc">রেটেড ব্যাটারি আয়ু থেকে শুরু করুন।</string>
<string name="device_battery_estimate_reset_confirm_title">শেখা ডেটা রিসেট করুন?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod এই ডিভাইসের পরিমাপ করা ড্রেইন, চার্জিং গতি এবং ব্যাটারি স্বাস্থ্য ভুলে যাবে এবং রেটেড ব্যাটারি আয়ু থেকে শুরু করবে।</string>
<string name="settings_acknowledgements_label">স্বীকৃতি</string>
<string name="settings_debug_autoreports_label">স্বয়ংক্রিয় বাগ রিপোর্ট</string>
<string name="settings_debug_autoreports_description">স্বয়ংক্রিয়ভাবে সমস্যাগুলি রিপোর্ট করে, যেমন অ্যাপ ক্র্যাশের বিবরণ যাতে আমি এটি কীভাবে ঠিক করতে পারি তা বের করতে পারি।</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">পটভূমি স্বচ্ছতা</string>
<string name="widget_config_show_device_label">ডিভাইসের নাম দেখান</string>
<string name="widget_config_reset_label">ডিফল্টে পুনরায় সেট করুন</string>
<string name="widget_config_preview_resize_hint">আপনি হোম স্ক্রিনে রাখার পরে উইজেটটি পুনরায় আকার দিতে পারেন।</string>
<string name="widget_config_preview_resize_hint">আপনার হোম স্ক্রিনে উইজেটটি রাখার পরে আপনি এটির আকার পরিবর্তন করতে পারেন।</string>
<string name="widget_config_custom_label">কাস্টম</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">গাঢ়</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth নিষ্ক্রিয়, এটি সক্রিয় করুন ;)</string>
<string name="overview_monitoring_active_label">ডিভাইসের জন্য পর্যবেক্ষণ চলছে</string>
<string name="overview_monitoring_active_description">নিশ্চিত করুন যে আপনার ডিভাইস কাছে আছে এবং সক্রিয়।</string>
<string name="overview_troubleshoot_suggestion_label">সংযুক্ত, কিন্তু কোনো ডেটা নেই</string>
<string name="overview_troubleshoot_suggestion_description">আপনার ডিভাইসটি সংযুক্ত, কিন্তু CAPod এটি থেকে কোনো লাইভ ডেটা পাচ্ছে না। আপনার ফোনের একটি সামঞ্জস্যতা বিকল্পের প্রয়োজন হতে পারে — সমস্যা সমাধানকারী স্বয়ংক্রিয়ভাবে একটি খুঁজে পেতে চেষ্টা করতে পারে।</string>
<string name="overview_troubleshoot_suggestion_action">সমস্যা সমাধানকারী চালান</string>
<string name="overview_unmatched_devices_label">অমিলিত ডিভাইস</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">মিলিত প্রোফাইল ছাড়া %d ডিভাইস</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">সর্বশেষ দেখা: %s</string>
<string name="first_seen_x">প্রথম দেখা: %s</string>
<string name="battery_cached_label">শেষ জানা · %s</string>
<string name="battery_time_remaining_format_hm">%1$dঘ %2$dমি</string>
<string name="battery_time_remaining_format_h">%1$dঘ</string>
<string name="battery_time_remaining_format_m">%1$dমি</string>
<string name="permission_post_notifications_label">বিজ্ঞপ্তি দেখান</string>
<string name="permission_post_notifications_description">"CAPod-কে আপনার AirPods সম্পর্কে বিজ্ঞপ্তি দেখানোর অনুমতি দিন, যেমন সংযুক্ত থাকাকালীন তাদের বর্তমান অবস্থা।"</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">ডান পড সিরিয়াল</string>
<string name="device_settings_info_left_bonded_label">বাম পেয়ার করা</string>
<string name="device_settings_info_right_bonded_label">ডান পেয়ার করা</string>
<string name="device_settings_info_battery_health_label">ব্যাটারি স্বাস্থ্য (অনুমানিত)</string>
<string name="device_settings_info_battery_health_left_label">বাম ব্যাটারি স্বাস্থ্য (অনু.)</string>
<string name="device_settings_info_battery_health_right_label">ডান ব্যাটারি স্বাস্থ্য (অনু.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">এখনও নির্ধারণ করছি, পরে আবার চেক করুন</string>
<string name="device_settings_info_details_label">ডিভাইস বিস্তারিত</string>
<string name="device_settings_info_details_action">ডিভাইস বিস্তারিত দেখুন</string>
<string name="device_settings_info_status_label">স্ট্যাটাস</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">ঘুম শনাক্তকরণ</string>
<string name="reaction_sleep_notification_title">ঘুম শনাক্তকরণ দ্বারা বিরতি দেওয়া হয়েছে</string>
<string name="reaction_sleep_notification_text">%1$s জানিয়েছে আপনি ঘুমিয়ে পড়েছেন, তাই আপনার সঙ্গীত বিরতি দেওয়া হয়েছে। আপনি ডিভাইস সেটিংসে এটি নিষ্ক্রিয় করতে পারেন।</string>
<string name="reaction_charged_channel_label">চার্জ সম্পন্ন বিজ্ঞপ্তি</string>
<string name="reaction_charged_notification_title">চার্জিং সম্পন্ন</string>
<string name="reaction_charged_notification_text_full">%1$s সম্পূর্ণ চার্জ হয়েছে।</string>
<string name="reaction_charged_notification_text_partial">%1$s %2$d%% পর্যন্ত চার্জ হয়েছে।</string>
<string name="device_settings_rename_label">নাম বদলান</string>
<string name="device_settings_rename_hint">ডিভাইসের নাম</string>
<string name="device_settings_rename_confirm">নাম বদলান</string>
+36 -6
View File
@@ -47,9 +47,9 @@
<string name="settings_autopause_label">Pausa automàtica</string>
<string name="settings_autopause_description">Pausa l\'àudio quan ús tragueu el dispositiu de l\'orella.</string>
<string name="settings_autopplay_label">Reproducció automàtica</string>
<string name="settings_autoplay_description">Repren la música quan tornes a posar-te els auriculars, però només si la pausa automàtica l\'havia aturat. Les pauses que has activat tu mateix (telèfon, pal dels AirPods, son) romanen en pausa.</string>
<string name="settings_start_music_on_wear_label">Inicia la música en posar-se els auriculars</string>
<string name="settings_start_music_on_wear_description">Sempre intenta reproduir música quan et poses els auriculars, fins i tot després d\'una pausa manual.</string>
<string name="settings_autoplay_description">Reprèn la música quan torneu a posar-vos els auriculars, però només si la pausa automàtica l\'havia aturat. Les pauses que heu activat vos mateix (telèfon, pliques dels AirPods, son) romanen en pausa.</string>
<string name="settings_start_music_on_wear_label">Inicia la música en posar-vos els auriculars</string>
<string name="settings_start_music_on_wear_description">Sempre intenta reproduir música quan us poseu els auriculars, fins i tot després d\'una pausa manual.</string>
<string name="settings_eardetection_info_label">Nota de detecció de l\'oïda</string>
<string name="settings_eardetection_info_description">Si la detecció de l\'oïda només funciona per a un auricular, això és degut a una limitació d\'Apple. Només es detecta «l\'auricular principal» (utilitzat per al micròfon). Configureu-ho en dispositius Apple: Configuració → Bluetooth → AirPods → Micròfon.</string>
<string name="settings_signal_minimum_label">Qualitat de senyal mínima</string>
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Mostra la finestra emergent de connexió</string>
<string name="settings_popup_connected_description">Mostra una finestra emergent quan el dispositiu es connecti per primera vegada.</string>
<string name="settings_popup_info_not_in_app">Les finestres emergents només es mostren quan no esteu a l\'aplicació.</string>
<string name="settings_charged_notification_label">Notificació de càrrega completa</string>
<string name="settings_charged_notification_description">Mostra una notificació quan tots els dispositius que s\'estiguin carregant arribin al nivell de càrrega desitjat.</string>
<string name="settings_charged_threshold_label">Nivell de càrrega objectiu</string>
<string name="settings_charged_scope_label">Bateries monitoritzades</string>
<string name="settings_charged_scope_pods_label">Només els auriculars</string>
<string name="settings_charged_scope_case_label">Només la funda</string>
<string name="settings_charged_scope_both_label">Auriculars i funda</string>
<string name="device_settings_experimental_title">Funció experimental</string>
<string name="device_settings_experimental_description">Aquesta funció encara no s\'ha provat a tots els dispositius. Si alguna cosa no funciona correctament, obriu una incidència amb un registre de depuració.</string>
<string name="device_settings_experimental_action">Obre el seguidor d\'incidències</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Depuració</string>
<string name="settings_general_label">Configuració</string>
<string name="settings_general_description">Configuracions generals que afecten a tota l\'aplicació.</string>
<string name="settings_overview_hide_unmatched_label">Amaga els dispositius no coincidents</string>
<string name="settings_overview_hide_unmatched_description">No mostris els dispositius propers que no coincideixen amb cap dels perfils a la vista general. P. ex. els AirPods daltres persones.</string>
<string name="device_battery_estimate_toggle_label">Estimació de la bateria</string>
<string name="device_battery_estimate_card_desc">Obteniu informació sobre com es carrega i s\'esgota aquest dispositiu per estimar el temps restant, el temps fins que es carrega completament i l\'estat de la bateria.</string>
<string name="device_battery_estimate_reset_action">Restableix les dades apreses</string>
<string name="device_battery_estimate_reset_desc">Comença de nou des de la durada nominal de la bateria.</string>
<string name="device_battery_estimate_reset_confirm_title">Voleu restablir les dades apreses?</string>
<string name="device_battery_estimate_reset_confirm_message">El CAPod oblidarà el consum mesurat d\'aquest dispositiu, la velocitat de càrrega i la salut de la bateria, i tornarà a començar des de la durada nominal de la bateria.</string>
<string name="settings_acknowledgements_label">Agraïments</string>
<string name="settings_debug_autoreports_label">Informes automàtics d\'errors</string>
<string name="settings_debug_autoreports_description">Informa automàticament de problemes. Per exemple: detalls sobre un bloqueig de l\'aplicació perquè pugui esbrinar com solucionar-ho.</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">El Bluetooth està desactivat, activeu-lo ;)</string>
<string name="overview_monitoring_active_label">Monitorització de dispositius</string>
<string name="overview_monitoring_active_description">Assegureu-vos que el dispositiu estigui a prop i actiu.</string>
<string name="overview_troubleshoot_suggestion_label">Connectat, però sense dades</string>
<string name="overview_troubleshoot_suggestion_description">El dispositiu està connectat, però el CAPod no rep dades en directe. El telèfon pot necessitar una opció de compatibilitat: el solucionador de problemes pot intentar trobar-ne una automàticament.</string>
<string name="overview_troubleshoot_suggestion_action">Executa el solucionador de problemes</string>
<string name="overview_unmatched_devices_label">Dispositius no coincidents</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d dispositiu sense perfil coincident</item>
@@ -248,8 +266,8 @@
<string name="anc_mode_transparency">Transparència</string>
<string name="anc_mode_adaptive">Adaptatiu</string>
<string name="conversation_awareness_label">Consciència de conversa</string>
<string name="settings_conversation_action_label">Quan comences a parlar</string>
<string name="settings_conversation_action_nothing_label">No facis res</string>
<string name="settings_conversation_action_label">Quan comenceu a parlar</string>
<string name="settings_conversation_action_nothing_label">No fer res</string>
<string name="settings_conversation_action_lower_volume_label">Baixa el volum</string>
<string name="settings_conversation_action_pause_label">Posa en pausa el reproductor</string>
<string name="settings_conversation_volume_reduction_label">Reducció del volum</string>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Últim cop vist: %s</string>
<string name="first_seen_x">Primera vegada vist: %s</string>
<string name="battery_cached_label">Última vegada vist · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Mostra notificacions</string>
<string name="permission_post_notifications_description">"Permet que el CAPod mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
<!-- Device profiles -->
@@ -288,7 +309,7 @@
<string name="profiles_delete_action">Elimina</string>
<string name="profiles_basic_info_title">Informació del dispositiu</string>
<string name="profiles_basic_info_description">Configureu el nom del dispositiu, el model i l\'emparellament Bluetooth opcional.</string>
<string name="profiles_signal_quality_title">Qualitat de senyal mínima</string>
<string name="profiles_signal_quality_title">Qualitat mínima del senyal</string>
<string name="profiles_signal_quality_description">Només es detecten dispositius amb una intensitat de senyal superior a aquest llindar. Els valors més baixos augmenten el rang de detecció, però poden causar falsos positius. No configureu aquest valor massa alt: la recepció Bluetooth generalment és deficient i varia segons la distància i els obstacles.</string>
<string name="profiles_identitykey_label">Clau d\'identitat</string>
<string name="profilessettings_maindevice_identitykey_description">La clau de resolució d\'identitat (IRK) del dispositiu ajuda al CAPod a identificar-lo entre els dispositius propers.</string>
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Número de sèrie de l\'auricular dret</string>
<string name="device_settings_info_left_bonded_label">Esquerre vinculat</string>
<string name="device_settings_info_right_bonded_label">Dret vinculat</string>
<string name="device_settings_info_battery_health_label">Salut de la bateria (estimat)</string>
<string name="device_settings_info_battery_health_left_label">Salut de la bateria esquerra (est.)</string>
<string name="device_settings_info_battery_health_right_label">Salut de la bateria dreta (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Encara s\'està determinant, torneu més tard</string>
<string name="device_settings_info_details_label">Detalls del dispositiu</string>
<string name="device_settings_info_details_action">Mostra els detalls del dispositiu</string>
<string name="device_settings_info_status_label">Estat</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Detecció del son</string>
<string name="reaction_sleep_notification_title">Pausat per la detecció del son</string>
<string name="reaction_sleep_notification_text">%1$s ha informat que us heu adormit, de manera que la vostra música s\'ha posat en pausa. Podeu desactivar-ho a la configuració del dispositiu.</string>
<string name="reaction_charged_channel_label">Notificació de càrrega completa</string>
<string name="reaction_charged_notification_title">Càrrega completada</string>
<string name="reaction_charged_notification_text_full">%1$s està totalment carregat.</string>
<string name="reaction_charged_notification_text_partial">%1$s s\'ha carregat fins al %2$d%%.</string>
<string name="device_settings_rename_label">Canvia el nom</string>
<string name="device_settings_rename_hint">Nom del dispositiu</string>
<string name="device_settings_rename_confirm">Canvia el nom</string>
+30
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Zobrazit vyskakovací okno při připojení</string>
<string name="settings_popup_connected_description">Zobrazit vyskakovací okno při prvním připojení zařízení.</string>
<string name="settings_popup_info_not_in_app">Vyskakovací okna jsou zobrazována pouze tehdy, když nejste v aplikaci.</string>
<string name="settings_charged_notification_label">Oznámení o nabití</string>
<string name="settings_charged_notification_description">Zobrazit oznámení, jakmile vše, co se nabíjí, dosáhne cílové úrovně nabití.</string>
<string name="settings_charged_threshold_label">Cílová úroveň nabití</string>
<string name="settings_charged_scope_label">Sledované baterie</string>
<string name="settings_charged_scope_pods_label">Pouze sluchátka</string>
<string name="settings_charged_scope_case_label">Pouze pouzdro</string>
<string name="settings_charged_scope_both_label">Sluchátka a pouzdro</string>
<string name="device_settings_experimental_title">Experimentální funkce</string>
<string name="device_settings_experimental_description">Tato funkce ještě nebyla testována na všech zařízeních. Pokud něco nefunguje správně, nahlaste problém s logem ladění.</string>
<string name="device_settings_experimental_action">Otevřít sledovač problémů</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Ladění</string>
<string name="settings_general_label">Nastavení</string>
<string name="settings_general_description">Obecná vylepšení, která ovlivňují celou aplikaci.</string>
<string name="settings_overview_hide_unmatched_label">Skrýt nepřiřazená zařízení</string>
<string name="settings_overview_hide_unmatched_description">V přehledu nezobrazovat blízká zařízení, která neodpovídají žádnému z vašich profilů, např. AirPods jiných lidí.</string>
<string name="device_battery_estimate_toggle_label">Odhady baterie</string>
<string name="device_battery_estimate_card_desc">Zjistěte, jak se toto zařízení nabíjí a vybíjí, abyste mohli odhadnout zbývající čas, čas do nabití a stav baterie.</string>
<string name="device_battery_estimate_reset_action">Obnovit naučená data</string>
<string name="device_battery_estimate_reset_desc">Začněte znovu od deklarované výdrže baterie.</string>
<string name="device_battery_estimate_reset_confirm_title">Obnovit naučená data?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod zapomene naměřený úbytek, rychlost nabíjení a stav baterie tohoto zařízení a začne znovu od deklarované výdrže baterie.</string>
<string name="settings_acknowledgements_label">Poděkování</string>
<string name="settings_debug_autoreports_label">Automatické hlášení chyb</string>
<string name="settings_debug_autoreports_description">Automatické hlášení chyb např. podrobnosti o pádech aplikace.</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth je vypnuto, zapněte jej ;)</string>
<string name="overview_monitoring_active_label">Monitorování zařízení</string>
<string name="overview_monitoring_active_description">Ujistěte se, že je vaše zařízení v dosahu a aktivní.</string>
<string name="overview_troubleshoot_suggestion_label">Připojeno, ale žádná data</string>
<string name="overview_troubleshoot_suggestion_description">Zařízení je připojeno, ale CAPod od něj nepřijímá žádná živá data. Váš telefon může potřebovat možnost kompatibility — řešení problémů ji může zkusit najít automaticky.</string>
<string name="overview_troubleshoot_suggestion_action">Spustit řešení problémů</string>
<string name="overview_unmatched_devices_label">Nepřiřazená zařízení</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d zařízení bez odpovídajícího profilu</item>
@@ -270,6 +288,9 @@
<string name="last_seen_x">Naposledy spatřeno: %s</string>
<string name="first_seen_x">Poprvé spatřeno: %s</string>
<string name="battery_cached_label">Poslední známé \u00B7 %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Zobrazit oznámení</string>
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</string>
<!-- Device profiles -->
@@ -421,6 +442,11 @@
<string name="device_settings_info_right_serial_label">Sériové číslo pravého sluchátka</string>
<string name="device_settings_info_left_bonded_label">Levé spárováno</string>
<string name="device_settings_info_right_bonded_label">Pravé spárováno</string>
<string name="device_settings_info_battery_health_label">Stav baterie (odhadovaný)</string>
<string name="device_settings_info_battery_health_left_label">Stav levé baterie (odh.)</string>
<string name="device_settings_info_battery_health_right_label">Stav pravé baterie (odh.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Stále se určuje, zkontrolujte později</string>
<string name="device_settings_info_details_label">Detaily zařízení</string>
<string name="device_settings_info_details_action">Zobrazit detaily zařízení</string>
<string name="device_settings_info_status_label">Stav</string>
@@ -500,6 +526,10 @@
<string name="reaction_sleep_channel_label">Detekce spánku</string>
<string name="reaction_sleep_notification_title">Pozastaveno detekcí spánku</string>
<string name="reaction_sleep_notification_text">%1$s oznámil, že jste usnuli, takže vaše hudba byla pozastavena. Toto můžete vypnout v nastavení zařízení.</string>
<string name="reaction_charged_channel_label">Oznámení o nabití</string>
<string name="reaction_charged_notification_title">Nabíjení dokončeno</string>
<string name="reaction_charged_notification_text_full">%1$s je plně nabito.</string>
<string name="reaction_charged_notification_text_partial">%1$s nabito na %2$d%%.</string>
<string name="device_settings_rename_label">Přejmenovat</string>
<string name="device_settings_rename_hint">Název zařízení</string>
<string name="device_settings_rename_confirm">Přejmenovat</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Vis forbindelses-pop op</string>
<string name="settings_popup_connected_description">Vis en pop op, når enheden tilsluttes for første gang.</string>
<string name="settings_popup_info_not_in_app">Pop-ups vises kun, når du ikke er i appen.</string>
<string name="settings_charged_notification_label">Opladt notifikation</string>
<string name="settings_charged_notification_description">Vis en notifikation, når alt der oplades når det ønskede opladningsniveau.</string>
<string name="settings_charged_threshold_label">Målopladningsniveau</string>
<string name="settings_charged_scope_label">Overvågede batterier</string>
<string name="settings_charged_scope_pods_label">Kun pods</string>
<string name="settings_charged_scope_case_label">Kun etui</string>
<string name="settings_charged_scope_both_label">Pods og etui</string>
<string name="device_settings_experimental_title">Eksperimentel funktion</string>
<string name="device_settings_experimental_description">Denne funktion er endnu ikke testet på alle enheder. Hvis noget ikke fungerer korrekt, bedes du åbne en sag med en fejlretningslog.</string>
<string name="device_settings_experimental_action">Åbn fejlsporer</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Fejlfinding</string>
<string name="settings_general_label">Indstillinger</string>
<string name="settings_general_description">Generelle justeringer, der påvirker hele appen.</string>
<string name="settings_overview_hide_unmatched_label">Skjul ikke-matchende enheder</string>
<string name="settings_overview_hide_unmatched_description">Vis ikke nærliggende enheder, der ikke matcher nogen af dine profiler i overblikkket, f.eks. andres AirPods.</string>
<string name="device_battery_estimate_toggle_label">Batteriestimater</string>
<string name="device_battery_estimate_card_desc">Lær, hvordan denne enhed oplades og aflades, for at estimere resterende tid, tid til opladning, og batteritilstand.</string>
<string name="device_battery_estimate_reset_action">Nulstil indlærte data</string>
<string name="device_battery_estimate_reset_desc">Start forfra fra den nominelle batterilevetid.</string>
<string name="device_battery_estimate_reset_confirm_title">Nulstil indlærte data?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod vil glemme denne enheds målte forbrug, opladningshastighed og batteritilstand, og starte forfra fra den nominelle batterilevetid.</string>
<string name="settings_acknowledgements_label">Anerkendelser</string>
<string name="settings_debug_autoreports_label">Automatiske fejlrapporter</string>
<string name="settings_debug_autoreports_description">Rapporterer automatisk problemer, f.eks. detaljer om et app-nedbrud, så jeg kan finde ud af, hvordan jeg løser det.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Baggrundsgennemsigtighed</string>
<string name="widget_config_show_device_label">Vis enhedsnavn</string>
<string name="widget_config_reset_label">Nulstil til standard</string>
<string name="widget_config_preview_resize_hint">Du kan ændre størrelsen på widgetten, efter du har placeret den på din startskærm.</string>
<string name="widget_config_preview_resize_hint">Du kan ændre størrelse på widgetten efter at have placeret den på din hjemmeskærm.</string>
<string name="widget_config_custom_label">Brugerdefineret</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Mørk</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth er deaktiveret, aktivér det ;)</string>
<string name="overview_monitoring_active_label">Overvåger for enheder</string>
<string name="overview_monitoring_active_description">Sørg for, at din enhed er i nærheden og aktiv.</string>
<string name="overview_troubleshoot_suggestion_label">Forbundet, men ingen data</string>
<string name="overview_troubleshoot_suggestion_description">Din enhed er forbundet, men CAPod modtager ikke live data fra den. Din telefon har måske brug for en kompatibilitetsindstilling — fejlfinderen kan prøve at finde en automatisk.</string>
<string name="overview_troubleshoot_suggestion_action">Kør fejlfinder</string>
<string name="overview_unmatched_devices_label">Uparrede enheder</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d enhed uden matchende profil</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Sidst set: %s</string>
<string name="first_seen_x">Først set: %s</string>
<string name="battery_cached_label">Sidst kendte · %s</string>
<string name="battery_time_remaining_format_hm">%1$dt %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dt</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Vis notifikationer</string>
<string name="permission_post_notifications_description">"Tillad CAPod at vise notifikationer om dine AirPods, f.eks. deres aktuelle status mens de er tilsluttet."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Højre pod-serienummer</string>
<string name="device_settings_info_left_bonded_label">Venstre parret</string>
<string name="device_settings_info_right_bonded_label">Højre parret</string>
<string name="device_settings_info_battery_health_label">Batteritilstand (estimeret)</string>
<string name="device_settings_info_battery_health_left_label">Venstre batteritilstand (est.)</string>
<string name="device_settings_info_battery_health_right_label">Højre batteritilstand (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Bestemmes stadig, kom tilbage senere</string>
<string name="device_settings_info_details_label">Enhedsdetaljer</string>
<string name="device_settings_info_details_action">Vis enhedsdetaljer</string>
<string name="device_settings_info_status_label">Status</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Søvnregistrering</string>
<string name="reaction_sleep_notification_title">Sat på pause af søvnregistrering</string>
<string name="reaction_sleep_notification_text">%1$s registrerede, at du faldt i søvn, så din musik blev sat på pause. Du kan deaktivere dette i enhedsindstillingerne.</string>
<string name="reaction_charged_channel_label">Opladt notifikation</string>
<string name="reaction_charged_notification_title">Opladning færdig</string>
<string name="reaction_charged_notification_text_full">%1$s er fuldt opladet.</string>
<string name="reaction_charged_notification_text_partial">%1$s er blevet opladet til %2$d%%.</string>
<string name="device_settings_rename_label">Omdøb</string>
<string name="device_settings_rename_hint">Enhedsnavn</string>
<string name="device_settings_rename_confirm">Omdøb</string>
+32 -2
View File
@@ -49,7 +49,7 @@
<string name="settings_autopplay_label">Automatisches abspielen</string>
<string name="settings_autoplay_description">Musik fortsetzen, wenn du deine Pods wieder trägst aber nur, wenn Auto-Pause sie gestoppt hat. Pausen, die du selbst ausgelöst hast (Telefon, AirPods-Stiel, Schlaf), bleiben aktiv.</string>
<string name="settings_start_music_on_wear_label">Musik beim Anlegen starten</string>
<string name="settings_start_music_on_wear_description">Versucht immer, Musik abzuspielen, wenn du deine Pods aufsetzt auch nach einer manuellen Pause.</string>
<string name="settings_start_music_on_wear_description">Versucht immer, Musik abzuspielen, wenn du deine Pods aufsetzt.</string>
<string name="settings_eardetection_info_label">Hinweis zur Ohr-Erkennung</string>
<string name="settings_eardetection_info_description">Wenn die Ohr-Erkennung nur für einen Pod funktioniert, ist das leider Apple-Einschränkung. Es wird dann nur der \"primäre Pod\" (Mikrofon) erkannt. Weitere Konfiguration auf Apple-Geräten: Einstellungen → Bluetooth → AirPods → Mikrofon.</string>
<string name="settings_signal_minimum_label">Minimale Signalqualität</string>
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Verbindungs-Popup anzeigen</string>
<string name="settings_popup_connected_description">Zeigt ein Popup an, wenn das Gerät zum ersten Mal eine Verbindung herstellt.</string>
<string name="settings_popup_info_not_in_app">Popups werden nur angezeigt, wenn du nicht in der App bist.</string>
<string name="settings_charged_notification_label">Aufgeladen-Benachrichtigung</string>
<string name="settings_charged_notification_description">Zeigt eine Benachrichtigung an, sobald alles, was geladen wird, den Ziel-Ladestand erreicht.</string>
<string name="settings_charged_threshold_label">Ziel-Ladestand</string>
<string name="settings_charged_scope_label">Überwachte Akkus</string>
<string name="settings_charged_scope_pods_label">Nur Pods</string>
<string name="settings_charged_scope_case_label">Nur Case</string>
<string name="settings_charged_scope_both_label">Pods und Case</string>
<string name="device_settings_experimental_title">Experimentelle Funktion</string>
<string name="device_settings_experimental_description">Diese Funktion wurde noch nicht auf allen Geräten getestet. Wenn etwas nicht korrekt funktioniert, öffne bitte ein Issue mit einem Debug-Log.</string>
<string name="device_settings_experimental_action">Issue-Tracker öffnen</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Debug</string>
<string name="settings_general_label">Einstellungen</string>
<string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string>
<string name="settings_overview_hide_unmatched_label">Nicht zugeordnete Geräte ausblenden</string>
<string name="settings_overview_hide_unmatched_description">Geräte in der Nähe, die keinem deiner Profile entsprechen, werden in der Übersicht nicht angezeigt, z. B. AirPods anderer Personen.</string>
<string name="device_battery_estimate_toggle_label">Batterieschätzungen</string>
<string name="device_battery_estimate_card_desc">Erfahre, wie dieses Gerät geladen und entladen wird, um die verbleibende Zeit, die Zeit bis zur Aufladung und die Batteriegesundheit zu schätzen.</string>
<string name="device_battery_estimate_reset_action">Gelernte Daten zurücksetzen</string>
<string name="device_battery_estimate_reset_desc">Nochmal von vorne mit der nominalen Batterielaufzeit beginnen.</string>
<string name="device_battery_estimate_reset_confirm_title">Gelernte Daten zurücksetzen?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod wird die gemessene Entladung, die Ladegeschwindigkeit und die Batteriegesundheit dieses Geräts vergessen und nochmal von vorne mit der nominalen Batterielaufzeit beginnen.</string>
<string name="settings_acknowledgements_label">Danksagungen</string>
<string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string>
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, z.B. Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth ist deaktiviert, aktiviere es ;)</string>
<string name="overview_monitoring_active_label">Überwachung für Geräte</string>
<string name="overview_monitoring_active_description">Stelle sicher, dass dein Gerät in der Nähe ist und aktiv ist.</string>
<string name="overview_troubleshoot_suggestion_label">Verbunden, aber keine Daten</string>
<string name="overview_troubleshoot_suggestion_description">Dein Gerät ist verbunden, aber CAPod empfängt keine Live-Daten davon. Dein Telefon benötigt möglicherweise eine Kompatibilitätsoption die Fehlerbehebung kann versuchen, automatisch eine zu finden.</string>
<string name="overview_troubleshoot_suggestion_action">Fehlerbehebung starten</string>
<string name="overview_unmatched_devices_label">Nicht zugeordnete Geräte</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d Gerät ohne passendes Profil</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Zuletzt gesehen: %s</string>
<string name="first_seen_x">Zum ersten Mal gesehen: %s</string>
<string name="battery_cached_label">Zuletzt bekannt · %s</string>
<string name="battery_time_remaining_format_hm">%1$d h %2$d Min</string>
<string name="battery_time_remaining_format_h">%1$d h</string>
<string name="battery_time_remaining_format_m">%1$d Min</string>
<string name="permission_post_notifications_label">Zeige Benachrichtigungen</string>
<string name="permission_post_notifications_description">"Erlaube CAPod, Benachrichtigungen über deine AirPods anzuzeigen, z. B. deren aktuellen Status, während die Verbindung besteht."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Seriennummer rechter Pod</string>
<string name="device_settings_info_left_bonded_label">Links gekoppelt</string>
<string name="device_settings_info_right_bonded_label">Rechts gekoppelt</string>
<string name="device_settings_info_battery_health_label">Batteriezustand (geschätzt)</string>
<string name="device_settings_info_battery_health_left_label">Linker Batteriezustand (gesch.)</string>
<string name="device_settings_info_battery_health_right_label">Rechter Batteriezustand (gesch.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Wird noch ermittelt, schau später nochmal</string>
<string name="device_settings_info_details_label">Gerätedetails</string>
<string name="device_settings_info_details_action">Gerätedetails anzeigen</string>
<string name="device_settings_info_status_label">Status</string>
@@ -468,7 +494,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Allgemein</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
<string name="device_settings_microphone_mode_description">Welches Ohrstöpsel als Mikrofon verwendet wird</string>
<string name="device_settings_microphone_mode_description">Welcher Ohrstöpsel als Mikrofon verwendet wird</string>
<string name="device_settings_microphone_mode_auto">Automatisch</string>
<string name="device_settings_microphone_mode_right">Rechts</string>
<string name="device_settings_microphone_mode_left">Links</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Schlaferkennung</string>
<string name="reaction_sleep_notification_title">Pausiert durch Schlafmodus-Erkennung</string>
<string name="reaction_sleep_notification_text">%1$s hat erkannt, dass du eingeschlafen bist, daher wurde deine Musik pausiert. Du kannst dies in den Geräteeinstellungen deaktivieren.</string>
<string name="reaction_charged_channel_label">Aufgeladen-Benachrichtigung</string>
<string name="reaction_charged_notification_title">Laden abgeschlossen</string>
<string name="reaction_charged_notification_text_full">%1$s ist vollständig geladen.</string>
<string name="reaction_charged_notification_text_partial">%1$s wurde auf %2$d%% geladen.</string>
<string name="device_settings_rename_label">Umbenennen</string>
<string name="device_settings_rename_hint">Gerätename</string>
<string name="device_settings_rename_confirm">Umbenennen</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Εμφάνιση αναδυόμενου παραθύρου σύνδεσης</string>
<string name="settings_popup_connected_description">Εμφάνιση αναδυόμενου παραθύρου όταν η συσκευή συνδέεται για πρώτη φορά.</string>
<string name="settings_popup_info_not_in_app">Τα αναδυόμενα παράθυρα εμφανίζονται μόνο όταν δεν βρίσκεστε στην εφαρμογή.</string>
<string name="settings_charged_notification_label">Ειδοποίηση φόρτισης</string>
<string name="settings_charged_notification_description">Εμφάνιση ειδοποίησης μόλις όλα τα στοιχεία που φορτίζουν φτάσουν στο επιθυμητό επίπεδο φόρτισης.</string>
<string name="settings_charged_threshold_label">Επιθυμητό επίπεδο φόρτισης</string>
<string name="settings_charged_scope_label">Παρακολουθούμενες μπαταρίες</string>
<string name="settings_charged_scope_pods_label">Μόνο ακουστικά</string>
<string name="settings_charged_scope_case_label">Μόνο θήκη</string>
<string name="settings_charged_scope_both_label">Ακουστικά και θήκη</string>
<string name="device_settings_experimental_title">Πειραματική λειτουργία</string>
<string name="device_settings_experimental_description">Αυτή η λειτουργία δεν έχει δοκιμαστεί ακόμα σε όλες τις συσκευές. Αν κάτι δεν λειτουργεί σωστά, παρακαλώ ανοίξτε ένα ζήτημα με ένα αρχείο καταγραφής εντοπισμού σφαλμάτων.</string>
<string name="device_settings_experimental_action">Άνοιγμα Παρακολούθησης Ζητημάτων</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Αποσφαλμάτωση</string>
<string name="settings_general_label">Ρυθμίσεις</string>
<string name="settings_general_description">Γενικές τροποποιήσεις που επηρεάζουν ολόκληρη την εφαρμογή.</string>
<string name="settings_overview_hide_unmatched_label">Απόκρυψη μη αντιστοιχισμένων συσκευών</string>
<string name="settings_overview_hide_unmatched_description">Να μην εμφανίζονται κοντινές συσκευές που δεν αντιστοιχούν σε κανένα από τα προφίλ σου στην επισκόπηση, π.χ. τα AirPods άλλων ανθρώπων.</string>
<string name="device_battery_estimate_toggle_label">Εκτιμήσεις μπαταρίας</string>
<string name="device_battery_estimate_card_desc">Μάθετε πώς φορτίζεται και εκφορτίζεται αυτή η συσκευή για να εκτιμήσετε τον υπόλοιπο χρόνο, τον χρόνο έως τη φόρτιση και την υγεία της μπαταρίας.</string>
<string name="device_battery_estimate_reset_action">Επαναφορά μαθημένων δεδομένων</string>
<string name="device_battery_estimate_reset_desc">Ξεκινήστε ξανά από τη χρήση μπαταρίας που αναφέρεται.</string>
<string name="device_battery_estimate_reset_confirm_title">Επαναφορά μαθημένων δεδομένων;</string>
<string name="device_battery_estimate_reset_confirm_message">Το CAPod θα ξεχάσει την μετρημένη κατανάλωση, την ταχύτητα φόρτισης και την υγεία της μπαταρίας αυτής της συσκευής, και θα ξεκινήσει ξανά από τη χρήση μπαταρίας που αναφέρεται.</string>
<string name="settings_acknowledgements_label">Ευχαριστίες</string>
<string name="settings_debug_autoreports_label">Αυτόματες αναφορές σφαλμάτων</string>
<string name="settings_debug_autoreports_description">Αναφέρει αυτόματα προβλήματα, π.χ. λεπτομέρειες σχετικά με μια κατάρρευση της εφαρμογής, ώστε να μπορώ να καταλάβω πώς να το διορθώσω.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Διαφάνεια φόντου</string>
<string name="widget_config_show_device_label">Εμφάνιση ονόματος συσκευής</string>
<string name="widget_config_reset_label">Επαναφορά στις προεπιλογές</string>
<string name="widget_config_preview_resize_hint">Μπορείτε να αλλάξετε το μέγεθος του widget αφού το τοποθετήσετε στην αρχική οθόνη σας.</string>
<string name="widget_config_preview_resize_hint">Μπορείτε να αλλάξετε το μέγεθος του widget μετά την τοποθέτησή του στην αρχική οθόνη σας.</string>
<string name="widget_config_custom_label">Προσαρμοσμένη</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Σκοτεινό</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Το Bluetooth είναι απενεργοποιημένο, ενεργοποιήστε το ;)</string>
<string name="overview_monitoring_active_label">Παρακολούθηση συσκευών</string>
<string name="overview_monitoring_active_description">Βεβαιωθείτε ότι η συσκευή σας είναι κοντά και ενεργή.</string>
<string name="overview_troubleshoot_suggestion_label">Συνδέθηκε, αλλά χωρίς δεδομένα</string>
<string name="overview_troubleshoot_suggestion_description">Η συσκευή σου είναι συνδεδεμένη, αλλά το CAPod δεν λαμβάνει ζωντανά δεδομένα από αυτήν. Το τηλέφωνό σου μπορεί να χρειαστεί μια επιλογή συμβατότητας — ο εντοπισμός προβλημάτων μπορεί να προσπαθήσει να βρει μία αυτόματα.</string>
<string name="overview_troubleshoot_suggestion_action">Εκτέλεση εντοπισμού προβλημάτων</string>
<string name="overview_unmatched_devices_label">Μη αντιστοιχισμένες συσκευές</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d συσκευή χωρίς αντίστοιχο προφίλ</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Τελευταία εμφάνιση: %s</string>
<string name="first_seen_x">Πρώτη εμφάνιση: %s</string>
<string name="battery_cached_label">Τελευταία γνωστή · %s</string>
<string name="battery_time_remaining_format_hm">%1$dώ %2$dλ</string>
<string name="battery_time_remaining_format_h">%1$dώ</string>
<string name="battery_time_remaining_format_m">%1$dλ</string>
<string name="permission_post_notifications_label">Εμφάνιση ειδοποιήσεων</string>
<string name="permission_post_notifications_description">"Επιτρέψτε στο CAPod να εμφανίζει ειδοποιήσεις σχετικά με τα AirPods σας, π.χ. την τρέχουσα κατάστασή τους ενώ είναι συνδεδεμένα."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Σειριακός Αριθμός Δεξιού Pod</string>
<string name="device_settings_info_left_bonded_label">Αριστερό Συζευγμένο</string>
<string name="device_settings_info_right_bonded_label">Δεξί Συζευγμένο</string>
<string name="device_settings_info_battery_health_label">Υγεία Μπαταρίας (εκτιμώμενη)</string>
<string name="device_settings_info_battery_health_left_label">Υγεία Αριστερής Μπαταρίας (εκτ.)</string>
<string name="device_settings_info_battery_health_right_label">Υγεία Δεξιάς Μπαταρίας (εκτ.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Εξακολουθεί να προσδιορίζεται, ελέγξτε αργότερα</string>
<string name="device_settings_info_details_label">Λεπτομέρειες Συσκευής</string>
<string name="device_settings_info_details_action">Εμφάνιση λεπτομερειών συσκευής</string>
<string name="device_settings_info_status_label">Κατάσταση</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Ανίχνευση Ύπνου</string>
<string name="reaction_sleep_notification_title">Σε παύση από την Ανίχνευση Ύπνου</string>
<string name="reaction_sleep_notification_text">%1$s ανέφερε ότι αποκοιμηθήκατε, οπότε η μουσική σας τέθηκε σε παύση. Μπορείτε να το απενεργοποιήσετε στις ρυθμίσεις συσκευής.</string>
<string name="reaction_charged_channel_label">Ειδοποίηση φόρτισης</string>
<string name="reaction_charged_notification_title">Η φόρτιση ολοκληρώθηκε</string>
<string name="reaction_charged_notification_text_full">Το %1$s έχει φορτιστεί πλήρως.</string>
<string name="reaction_charged_notification_text_partial">Το %1$s έχει φορτιστεί στο %2$d%%.</string>
<string name="device_settings_rename_label">Μετονομασία</string>
<string name="device_settings_rename_hint">Όνομα συσκευής</string>
<string name="device_settings_rename_confirm">Μετονομασία</string>
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Mostrar pop-up de conexión</string>
<string name="settings_popup_connected_description">Mostrar un pop-up cuando el dispositivo se conecta por primera vez.</string>
<string name="settings_popup_info_not_in_app">Los popups solo se muestran cuando no estás en la app.</string>
<string name="settings_charged_notification_label">Notificación de carga completa</string>
<string name="settings_charged_notification_description">Muestra una notificación cuando todo lo que se está cargando alcanza el nivel de carga objetivo.</string>
<string name="settings_charged_threshold_label">Nivel de carga objetivo</string>
<string name="settings_charged_scope_label">Baterías monitoreadas</string>
<string name="settings_charged_scope_pods_label">Solo pods</string>
<string name="settings_charged_scope_case_label">Solo estuche</string>
<string name="settings_charged_scope_both_label">Pods y estuche</string>
<string name="device_settings_experimental_title">Función experimental</string>
<string name="device_settings_experimental_description">Esta función aún no se ha probado en todos los dispositivos. Si algo no funciona correctamente, por favor abrí un problema con un registro de depuración.</string>
<string name="device_settings_experimental_action">Abrir seguimiento de problemas</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Depuración</string>
<string name="settings_general_label">Ajustes</string>
<string name="settings_general_description">Ajustes generales que afectan a toda la app.</string>
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej., los AirPods de otras personas.</string>
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
<string name="device_battery_estimate_card_desc">Aprendé cómo este dispositivo se carga y se descarga para estimar el tiempo restante, el tiempo hasta que se cargue y la salud de la batería.</string>
<string name="device_battery_estimate_reset_action">Reiniciar datos aprendidos</string>
<string name="device_battery_estimate_reset_desc">Comenzá desde cero con la vida útil nominal de la batería.</string>
<string name="device_battery_estimate_reset_confirm_title">¿Reiniciar datos aprendidos?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido, la velocidad de carga y la salud de la batería de este dispositivo, y comenzará de nuevo con la vida útil nominal de la batería.</string>
<string name="settings_acknowledgements_label">Agradecimientos</string>
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la app para que pueda averiguar cómo solucionarlo.</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">El Bluetooth está desactivado, actívalo ;)</string>
<string name="overview_monitoring_active_label">Monitoreo de dispositivos</string>
<string name="overview_monitoring_active_description">Asegúrate de que tu dispositivo esté cerca y activo.</string>
<string name="overview_troubleshoot_suggestion_label">Conectado, pero sin datos</string>
<string name="overview_troubleshoot_suggestion_description">Tu dispositivo está conectado, pero CAPod no está recibiendo datos en tiempo real. Tu teléfono puede necesitar una opción de compatibilidad: el solucionador de problemas puede intentar encontrar una automáticamente.</string>
<string name="overview_troubleshoot_suggestion_action">Ejecutar solucionador de problemas</string>
<string name="overview_unmatched_devices_label">Dispositivos no emparejados</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d dispositivo sin perfil coincidente</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Ultima vez conectado: %s</string>
<string name="first_seen_x">Primera vez conectados: %s</string>
<string name="battery_cached_label">Último conocido · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
<string name="device_settings_info_battery_health_label">Salud de la batería (estimada)</string>
<string name="device_settings_info_battery_health_left_label">Salud batería izq. (est.)</string>
<string name="device_settings_info_battery_health_right_label">Salud batería der. (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Aún determinando, volvé más tarde</string>
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
<string name="device_settings_info_status_label">Estado</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Detección de sueño</string>
<string name="reaction_sleep_notification_title">Pausado por Detección de sueño</string>
<string name="reaction_sleep_notification_text">%1$s informó que te quedaste dormido, por lo que tu música fue pausada. Podés desactivar esto en la configuración del dispositivo.</string>
<string name="reaction_charged_channel_label">Notificación de carga completa</string>
<string name="reaction_charged_notification_title">Carga completa</string>
<string name="reaction_charged_notification_text_full">%1$s está completamente cargado.</string>
<string name="reaction_charged_notification_text_partial">%1$s se ha cargado al %2$d%%.</string>
<string name="device_settings_rename_label">Renombrar</string>
<string name="device_settings_rename_hint">Nombre del dispositivo</string>
<string name="device_settings_rename_confirm">Renombrar</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Mostrar pop-up de conexión</string>
<string name="settings_popup_connected_description">Mostrar un pop-up cuando el dispositivo se conecta por primera vez.</string>
<string name="settings_popup_info_not_in_app">Las ventanas emergentes solo se muestran cuando no estás en la aplicación.</string>
<string name="settings_charged_notification_label">Notificación de carga completa</string>
<string name="settings_charged_notification_description">Muestra una notificación cuando todo lo que se está cargando alcanza el nivel de carga objetivo.</string>
<string name="settings_charged_threshold_label">Nivel de carga objetivo</string>
<string name="settings_charged_scope_label">Baterías monitoreadas</string>
<string name="settings_charged_scope_pods_label">Solo audífonos</string>
<string name="settings_charged_scope_case_label">Solo estuche</string>
<string name="settings_charged_scope_both_label">Audífonos y estuche</string>
<string name="device_settings_experimental_title">Función experimental</string>
<string name="device_settings_experimental_description">Esta función aún no ha sido probada en todos los dispositivos. Si algo no funciona correctamente, por favor abre un problema con un registro de depuración.</string>
<string name="device_settings_experimental_action">Abrir rastreador de problemas</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Depuración</string>
<string name="settings_general_label">Ajustes</string>
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
<string name="settings_overview_hide_unmatched_description">No mostrar dispositivos cercanos que no coincidan con ninguno de tus perfiles en el resumen, p. ej. los AirPods de otras personas.</string>
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
<string name="device_battery_estimate_card_desc">Aprende cómo se carga y descarga este dispositivo para estimar el tiempo restante, el tiempo hasta cargarse y la salud de la batería.</string>
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
<string name="device_battery_estimate_reset_desc">Comienza desde la vida útil nominal de la batería.</string>
<string name="device_battery_estimate_reset_confirm_title">¿Restablecer datos aprendidos?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido, la velocidad de carga y la salud de la batería de este dispositivo, y comenzará desde la vida útil nominal de la batería.</string>
<string name="settings_acknowledgements_label">Agradecimientos</string>
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la aplicación para que pueda averiguar cómo solucionarlo.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Transparencia de fondo</string>
<string name="widget_config_show_device_label">Mostrar nombre del dispositivo</string>
<string name="widget_config_reset_label">Restablecer predeterminados</string>
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en tu pantalla de inicio.</string>
<string name="widget_config_preview_resize_hint">Puedes redimensionar el widget después de colocarlo en tu pantalla de inicio.</string>
<string name="widget_config_custom_label">Personalizado</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Oscuro</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">El Bluetooth está desactivado, actívalo ;)</string>
<string name="overview_monitoring_active_label">Monitoreo de dispositivos</string>
<string name="overview_monitoring_active_description">Asegúrate de que tu dispositivo esté cerca y activo.</string>
<string name="overview_troubleshoot_suggestion_label">Conectado, pero sin datos</string>
<string name="overview_troubleshoot_suggestion_description">Tu dispositivo está conectado, pero CAPod no está recibiendo datos en tiempo real. Es posible que tu teléfono necesite una opción de compatibilidad — el solucionador de problemas puede intentar encontrar una automáticamente.</string>
<string name="overview_troubleshoot_suggestion_action">Ejecutar solucionador de problemas</string>
<string name="overview_unmatched_devices_label">Dispositivos no emparejados</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d dispositivo sin perfil coincidente</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Ultima vez conectado: %s</string>
<string name="first_seen_x">Primera vez conectados: %s</string>
<string name="battery_cached_label">Último conocido · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
<string name="device_settings_info_battery_health_label">Salud de la batería (estimada)</string>
<string name="device_settings_info_battery_health_left_label">Salud de la batería izquierda (est.)</string>
<string name="device_settings_info_battery_health_right_label">Salud de la batería derecha (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Aún se está determinando, vuelve más tarde</string>
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
<string name="device_settings_info_status_label">Estado</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Detección de sueño</string>
<string name="reaction_sleep_notification_title">Pausado por detección de sueño</string>
<string name="reaction_sleep_notification_text">%1$s detectó que te quedaste dormido, por lo que tu música fue pausada. Puedes desactivar esto en la configuración del dispositivo.</string>
<string name="reaction_charged_channel_label">Notificación de carga completa</string>
<string name="reaction_charged_notification_title">Carga completada</string>
<string name="reaction_charged_notification_text_full">%1$s está completamente cargado.</string>
<string name="reaction_charged_notification_text_partial">%1$s se ha cargado al %2$d%%.</string>
<string name="device_settings_rename_label">Renombrar</string>
<string name="device_settings_rename_hint">Nombre del dispositivo</string>
<string name="device_settings_rename_confirm">Renombrar</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Mostrar ventana emergente de conexión</string>
<string name="settings_popup_connected_description">Mostrar una ventana emergente cuando el dispositivo se conecta por primera vez.</string>
<string name="settings_popup_info_not_in_app">Los popups solo se muestran cuando no estás en la aplicación.</string>
<string name="settings_charged_notification_label">Notificación de carga completa</string>
<string name="settings_charged_notification_description">Mostrar una notificación cuando todo lo que se está cargando alcance el nivel de carga objetivo.</string>
<string name="settings_charged_threshold_label">Nivel de carga objetivo</string>
<string name="settings_charged_scope_label">Baterías supervisadas</string>
<string name="settings_charged_scope_pods_label">Solo auriculares</string>
<string name="settings_charged_scope_case_label">Solo funda</string>
<string name="settings_charged_scope_both_label">Auriculares y funda</string>
<string name="device_settings_experimental_title">Función experimental</string>
<string name="device_settings_experimental_description">Esta función aún no se ha probado en todos los dispositivos. Si algo no funciona correctamente, por favor abre una incidencia con un registro de depuración.</string>
<string name="device_settings_experimental_action">Abrir rastreador de incidencias</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Depuración</string>
<string name="settings_general_label">Ajustes</string>
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no reconocidos</string>
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej. los AirPods de otras personas.</string>
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
<string name="device_battery_estimate_card_desc">Aprende cómo se carga y descarga este dispositivo para estimar el tiempo restante, el tiempo hasta que se cargue completamente y la salud de la batería.</string>
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
<string name="device_battery_estimate_reset_desc">Comienza de nuevo desde la duración nominal de la batería.</string>
<string name="device_battery_estimate_reset_confirm_title">¿Restablecer datos aprendidos?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido de este dispositivo, la velocidad de carga y la salud de la batería, y comenzará de nuevo desde la duración nominal de la batería.</string>
<string name="settings_acknowledgements_label">Agradecimientos</string>
<string name="settings_debug_autoreports_label">Informes de error automáticos</string>
<string name="settings_debug_autoreports_description">Informa automáticamente de los problemas, p. ej., detalles sobre un fallo de la aplicación para que pueda averiguar cómo solucionarlo.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Transparencia del fondo</string>
<string name="widget_config_show_device_label">Mostrar el nombre del dispositivo</string>
<string name="widget_config_reset_label">Restaurar los valores predeterminados</string>
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en la pantalla de inicio.</string>
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en tu pantalla de inicio.</string>
<string name="widget_config_custom_label">Personalizado</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Oscuro</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">El Bluetooth está desactivado, actívalo ;)</string>
<string name="overview_monitoring_active_label">Monitoreo de dispositivos</string>
<string name="overview_monitoring_active_description">Asegúrate de que tu dispositivo esté cerca y activo.</string>
<string name="overview_troubleshoot_suggestion_label">Conectado, pero sin datos</string>
<string name="overview_troubleshoot_suggestion_description">Tu dispositivo está conectado, pero CAPod no está recibiendo datos en directo. Es posible que tu teléfono necesite una opción de compatibilidad: el solucionador de problemas puede intentar encontrar una automáticamente.</string>
<string name="overview_troubleshoot_suggestion_action">Ejecutar solucionador de problemas</string>
<string name="overview_unmatched_devices_label">Dispositivos no emparejados</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d dispositivo sin perfil coincidente</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Ultima vez conectado: %s</string>
<string name="first_seen_x">Primera vez conectados: %s</string>
<string name="battery_cached_label">Último conocido · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
<string name="device_settings_info_battery_health_label">Salud de la batería (estimado)</string>
<string name="device_settings_info_battery_health_left_label">Salud de la batería izquierda (est.)</string>
<string name="device_settings_info_battery_health_right_label">Salud de la batería derecha (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Aún se está determinando, comprueba más tarde</string>
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
<string name="device_settings_info_status_label">Estado</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Detección de sueño</string>
<string name="reaction_sleep_notification_title">Pausado por detección de sueño</string>
<string name="reaction_sleep_notification_text">%1$s informó que te quedaste dormido, por lo que tu música fue pausada. Puedes desactivar esto en la configuración del dispositivo.</string>
<string name="reaction_charged_channel_label">Notificación de carga completa</string>
<string name="reaction_charged_notification_title">Carga completada</string>
<string name="reaction_charged_notification_text_full">%1$s está completamente cargado.</string>
<string name="reaction_charged_notification_text_partial">%1$s se ha cargado al %2$d%%.</string>
<string name="device_settings_rename_label">Renombrar</string>
<string name="device_settings_rename_hint">Nombre del dispositivo</string>
<string name="device_settings_rename_confirm">Renombrar</string>
+38 -8
View File
@@ -47,9 +47,9 @@
<string name="settings_autopause_label">Automaatne peatamine</string>
<string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string>
<string name="settings_autopplay_label">Automaatne esitamine</string>
<string name="settings_autoplay_description">Jätka muusika esitust, kui kõrvaklapid jälle seljas, kuid ainult siis, kui Auto-paus selle peatas. Pausid, mille tegid ise (telefon, AirPodsi vars, uni), jäävad pausi peale.</string>
<string name="settings_start_music_on_wear_label">Käivita muusika kandmisel</string>
<string name="settings_start_music_on_wear_description">Proovib alati mängida muusikat, kui paned kõrvaklapid pähe, isegi pärast käsitsi pausi.</string>
<string name="settings_autoplay_description">Jätka muusika kuulamist, kui kannad taas kõrvaklappe, kuid ainult siis, kui isekäivitunud paus selle peatas. Isepausimise (telefon, AirPodsi vars, uni) puhul jääb paus kestma.</string>
<string name="settings_start_music_on_wear_label">Esita muusikat kõrva panemisel</string>
<string name="settings_start_music_on_wear_description">Proovib alati esitada muusikat, kui paned kõrvaklapid pähe, isegi pärast enda käivitatud pausi.</string>
<string name="settings_eardetection_info_label">Kõrva tuvastamisest</string>
<string name="settings_eardetection_info_description">Kui tuvastatakse vaid üks klapp, siis seda põhjustab Apple\'i piirang. Tuvastatakse vaid peamine klapp (kasutatakse mikrofonina). Seadista Apple\'i seadmetes: Seaded (Settings) → Bluetooth → AirPods (AirPodid) → Mikrofon (Microphone).</string>
<string name="settings_signal_minimum_label">Väikseim signaali kvaliteet</string>
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Näita ühenduse hüpikakent</string>
<string name="settings_popup_connected_description">Näita seadme esmaühendumisel hüpikakent.</string>
<string name="settings_popup_info_not_in_app">Hüpikaknad kuvatakse ainult siis, kui sa pole rakenduses.</string>
<string name="settings_charged_notification_label">Laetud teatis</string>
<string name="settings_charged_notification_description">Kuva teatis, kui kõik laadimise all olevad seadmed jõuavad sihitud laetuse tasemele.</string>
<string name="settings_charged_threshold_label">Sihitud laetuse tase</string>
<string name="settings_charged_scope_label">Jälgitavad akud</string>
<string name="settings_charged_scope_pods_label">Ainult kõrvaklapid</string>
<string name="settings_charged_scope_case_label">Ainult ümbris</string>
<string name="settings_charged_scope_both_label">Kõrvaklapid ja ümbris</string>
<string name="device_settings_experimental_title">Katsetamisel</string>
<string name="device_settings_experimental_description">Seda pole kõigil seadmetel veel katsetatud. Kui midagi ei toimi õigesti, avage probleem koos silumislogi failiga.</string>
<string name="device_settings_experimental_action">Ava probleemide jälgija</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Silumine</string>
<string name="settings_general_label">Seaded</string>
<string name="settings_general_description">Kogu rakendust mõjutavad üldseaded.</string>
<string name="settings_overview_hide_unmatched_label">Peida mittevastavad seadmed</string>
<string name="settings_overview_hide_unmatched_description">Ära näita ülevaates lähedalasuvaid seadmeid, mis ei vasta ühelegi sinu profiilile, nt teiste inimeste AirPods.</string>
<string name="device_battery_estimate_toggle_label">Aku hinnangud</string>
<string name="device_battery_estimate_card_desc">Saate teada, kuidas see seade laadib ja tühjeneb, et hinnata ülejäänud aega, laadimiseni kuluva aega ja aku tervist.</string>
<string name="device_battery_estimate_reset_action">Lähtesta õpitud andmed</string>
<string name="device_battery_estimate_reset_desc">Alusta uuesti nimiväärtustest.</string>
<string name="device_battery_estimate_reset_confirm_title">Lähtesta õpitud andmed?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod unustab selle seadme mõõdetud kulu, laadimiskiiruse ja aku tervise ning alustab uuesti nimiväärtustest.</string>
<string name="settings_acknowledgements_label">Tunnustused</string>
<string name="settings_debug_autoreports_label">Automaatsed veateated</string>
<string name="settings_debug_autoreports_description">Teavitab vigadest ise, nt andmed rakenduse hangumise kohta, et saaksin mõista, kuidas olukorda lahendada.</string>
@@ -127,13 +142,13 @@
<string name="anc_widget_config_aap_required_hint">See vidin nõuab otsest ühendust (AAP) teie seadmega. Enne selle vidina kasutamist veenduge, et teie seade on rakenduses seotud ja ühendatud.</string>
<string name="anc_widget_no_anc_support_label">See seade ei toeta mürakontrolli</string>
<string name="anc_widget_aap_not_connected_label">Ühendus puudub</string>
<string name="anc_widget_aap_not_connected_nearby_description">Lähedal, kuid ei ole ühendatud</string>
<string name="anc_widget_aap_not_connected_nearby_description">Lähedal, kuid pole ühendatud</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Pole lähedal</string>
<string name="anc_widget_aap_connecting_label">Ühendamine…</string>
<string name="tile_anc_label">Mürakontroll</string>
<string name="tile_anc_subtitle_permission_required">Luba nõutud</string>
<string name="tile_anc_subtitle_permission_required">Vaja on luba</string>
<string name="tile_anc_subtitle_no_device">Puudub seade</string>
<string name="tile_anc_subtitle_no_anc_support">Mürakontroll puudub</string>
<string name="tile_anc_subtitle_no_anc_support">Puudub mürakontroll</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth väljas</string>
<string name="widget_config_screen_title">Vidina seadistus</string>
<string name="widget_configuration_title">Vali seade</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth on keelatud, lubage see ;)</string>
<string name="overview_monitoring_active_label">Seadmete jälgimine</string>
<string name="overview_monitoring_active_description">Veenduge, et seade oleks läheduses ja kasutusel.</string>
<string name="overview_troubleshoot_suggestion_label">Ühendatud, kuid andmeid pole</string>
<string name="overview_troubleshoot_suggestion_description">Seade on ühendatud, kuid CAPod ei saa sellelt otseandmeid. Telefon vajab tõenäoliselt ühilduvuse valikut — trikijär kontrollib, kas suudab selle automaatselt leida.</string>
<string name="overview_troubleshoot_suggestion_action">Käivita trikijär</string>
<string name="overview_unmatched_devices_label">Omavahel sobimatud seadmed</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d seade ei kattu profiiliga</item>
@@ -250,7 +268,7 @@
<string name="conversation_awareness_label">Vestluse tuvastamine</string>
<string name="settings_conversation_action_label">Kui alustad rääkimist</string>
<string name="settings_conversation_action_nothing_label">Ära tee midagi</string>
<string name="settings_conversation_action_lower_volume_label">Alanda helitugevust</string>
<string name="settings_conversation_action_lower_volume_label">Vähenda helitugevust</string>
<string name="settings_conversation_action_pause_label">Peata meedia</string>
<string name="settings_conversation_volume_reduction_label">Helitugevuse vähendamine</string>
<string name="signal_badge_ble_cd">BLE skannimine aktiivne</string>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Viimati kasutusel: %s</string>
<string name="first_seen_x">Esimesena kasutusel: %s</string>
<string name="battery_cached_label">Viimati teadaolev · %s</string>
<string name="battery_time_remaining_format_hm">%1$dt %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dt</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Näita teateid</string>
<string name="permission_post_notifications_description">"Luba CAPodil näidata AirPodsi teateid, nt praegust olekut ühendatud olles."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Parema kõrvaklapiosa seerianumber</string>
<string name="device_settings_info_left_bonded_label">Vasak seotud</string>
<string name="device_settings_info_right_bonded_label">Parem seotud</string>
<string name="device_settings_info_battery_health_label">Aku tervis (hinnanguline)</string>
<string name="device_settings_info_battery_health_left_label">Vasaku aku tervis (hinnanguline)</string>
<string name="device_settings_info_battery_health_right_label">Parema aku tervis (hinnanguline)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Määratakse, vaata hiljem uuesti</string>
<string name="device_settings_info_details_label">Seadme andmed</string>
<string name="device_settings_info_details_action">Kuva seadme andmed</string>
<string name="device_settings_info_status_label">Olek</string>
@@ -468,7 +494,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Üldine</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
<string name="device_settings_microphone_mode_description">Millist kõrvaklappi kasutatakse mikrofonina</string>
<string name="device_settings_microphone_mode_description">Kumba kõrvaklappi kasutatakse mikrofonina</string>
<string name="device_settings_microphone_mode_auto">Automaatne</string>
<string name="device_settings_microphone_mode_right">Parem</string>
<string name="device_settings_microphone_mode_left">Vasak</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Une tuvastamine</string>
<string name="reaction_sleep_notification_title">Uinumise tõttu peatatud</string>
<string name="reaction_sleep_notification_text">%1$s teatas, et uinusite, seega muusika vaikis. Saate selle seadme seadetes keelata.</string>
<string name="reaction_charged_channel_label">Laetud teatis</string>
<string name="reaction_charged_notification_title">Laadimine lõpetatud</string>
<string name="reaction_charged_notification_text_full">%1$s on täielikult laetud.</string>
<string name="reaction_charged_notification_text_partial">%1$s on laetud %2$d%%-ni.</string>
<string name="device_settings_rename_label">Nimeta ümber</string>
<string name="device_settings_rename_hint">Seadme nimi</string>
<string name="device_settings_rename_confirm">Nimeta ümber</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Erakutsi konexioaren pop-up-a</string>
<string name="settings_popup_connected_description">Erakutsi pop-up bat gailua lehen aldiz konektatzen denean.</string>
<string name="settings_popup_info_not_in_app">Leiho gainerakorrak soilik erakusten dira aplikazioan ez zaudenerako.</string>
<string name="settings_charged_notification_label">Kargatutako jakinarazpena</string>
<string name="settings_charged_notification_description">Erakutsi jakinarazpen bat kargatzen ari den guztia helburuko karga-maila lortzen duenean.</string>
<string name="settings_charged_threshold_label">Helburuko karga-maila</string>
<string name="settings_charged_scope_label">Monitorizatutako bateriak</string>
<string name="settings_charged_scope_pods_label">Podak soilik</string>
<string name="settings_charged_scope_case_label">Kaxa soilik</string>
<string name="settings_charged_scope_both_label">Podak eta kaxa</string>
<string name="device_settings_experimental_title">Funtzio esperimentala</string>
<string name="device_settings_experimental_description">Funtzio hau oraindik ez da gailu guztietan probatu. Zerbait ondo funtzionatzen ez badu, mesedez ireki arazo bat arazketa-erregistroarekin.</string>
<string name="device_settings_experimental_action">Ireki arazo-jarraipena</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Arazketa</string>
<string name="settings_general_label">Ezarpenak</string>
<string name="settings_general_description">Aplikazio osoari eragiten dioten doikuntza orokorrak.</string>
<string name="settings_overview_hide_unmatched_label">Ezkutatu bat ez datozen gailuak</string>
<string name="settings_overview_hide_unmatched_description">Ez erakutsi ikuspegiaren inguruan dauden eta zure profilekin bat ez datozen gailuak, esaterako, beste pertsonen AirPods-ak.</string>
<string name="device_battery_estimate_toggle_label">Bateriaren estimazioak</string>
<string name="device_battery_estimate_card_desc">Ikasi nola kargatzen eta deskargatzen den gailu hau, geratzen den denbora, kargatzeko denbora eta bateriaren osasuna estimatzeko.</string>
<string name="device_battery_estimate_reset_action">Ikasitako datuak berrezarri</string>
<string name="device_battery_estimate_reset_desc">Kalifikatutako bateriaren bizitzatik berriro hasi.</string>
<string name="device_battery_estimate_reset_confirm_title">Ikasitako datuak berrezarri?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod-ek gailu honetako neurtutako kontsumoa, kargatzeko abiadura eta bateriaren osasuna ahaztuko ditu, eta kalifikatutako bateriaren bizitzatik berriro hasiko da.</string>
<string name="settings_acknowledgements_label">Eskertzak</string>
<string name="settings_debug_autoreports_label">Erroreen txosten automatikoak</string>
<string name="settings_debug_autoreports_description">Automatikoki arazoak salatzen ditu, adib. aplikazioaren kraskaduraren xehetasunak, konpontzen jakin dezadan.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Atzeko planoaren gardentasuna</string>
<string name="widget_config_show_device_label">Gailuaren izena erakutsi</string>
<string name="widget_config_reset_label">Berriro lehenespenera berrezarri</string>
<string name="widget_config_preview_resize_hint">Widgeta tamainaz alda dezakezu hasierako pantailan jarri ondoren.</string>
<string name="widget_config_preview_resize_hint">Widgeta tamaina aldatu dezakezu, hasierako pantailan jarri ondoren.</string>
<string name="widget_config_custom_label">Pertsonalizatua</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Iluna</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth-a desgaituta dago, gaitu ezazu ;)</string>
<string name="overview_monitoring_active_label">Gailuen jarraipena</string>
<string name="overview_monitoring_active_description">Ziurtatu zure gailua gertuan eta aktibo dagoela.</string>
<string name="overview_troubleshoot_suggestion_label">Konektatuta, baina daturik ez</string>
<string name="overview_troubleshoot_suggestion_description">Zure gailua konektatuta dago, baina CAPod-ek ez du zuzeneko daturik jasotzen. Baliteke zure telefonoak bateragarritasun-aukera bat behar izatea — arazo-konpontzaileak automatikoki bila dezake.</string>
<string name="overview_troubleshoot_suggestion_action">Exekutatu arazo-konpontzailea</string>
<string name="overview_unmatched_devices_label">Bat ez datozen gailuak</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d gailu bat datorren profilik gabe</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Azkenekoz ikusia: %s</string>
<string name="first_seen_x">Lehen aldiz ikusia: %s</string>
<string name="battery_cached_label">Azken ezaguna · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Erakutsi jakinarazpenak</string>
<string name="permission_post_notifications_description">"Baimendu CAPod-i zure AirPods-ei buruzko jakinarazpenak erakustea, adib. konektatuta dauden bitartean euren egungo egoera."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Eskuineko pod-aren seriea</string>
<string name="device_settings_info_left_bonded_label">Ezkerra Lotuta</string>
<string name="device_settings_info_right_bonded_label">Eskuina Lotuta</string>
<string name="device_settings_info_battery_health_label">Bateriaren osasuna (kalkulatua)</string>
<string name="device_settings_info_battery_health_left_label">Ezkerreko bateriaren osasuna (est.)</string>
<string name="device_settings_info_battery_health_right_label">Eskuineko bateriaren osasuna (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Oraindik kalkulatzen, beranduago itzuli</string>
<string name="device_settings_info_details_label">Gailuaren xehetasunak</string>
<string name="device_settings_info_details_action">Erakutsi gailuaren xehetasunak</string>
<string name="device_settings_info_status_label">Egoera</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Lo Detekzioa</string>
<string name="reaction_sleep_notification_title">Lo Detekzioak Pausatuta</string>
<string name="reaction_sleep_notification_text">%1$s-ek jakinarazi du lo geratu zarela, eta musikak pausatu egin zen. Gailu-ezarpenetan desgaitu dezakezu.</string>
<string name="reaction_charged_channel_label">Kargatutako jakinarazpena</string>
<string name="reaction_charged_notification_title">Karga osatu da</string>
<string name="reaction_charged_notification_text_full">%1$s guztiz kargatuta dago.</string>
<string name="reaction_charged_notification_text_partial">%1$s %2$d%%-era kargatu da.</string>
<string name="device_settings_rename_label">Berrizendatu</string>
<string name="device_settings_rename_hint">Gailuaren izena</string>
<string name="device_settings_rename_confirm">Berrizendatu</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">نمایش پاپ‌آپ اتصال</string>
<string name="settings_popup_connected_description">هنگام اتصال دستگاه برای اولین بار، یک پاپ‌آپ نشان داده شود.</string>
<string name="settings_popup_info_not_in_app">پنجره‌های بازشو فقط زمانی نشان داده می‌شوند که در برنامه نباشید.</string>
<string name="settings_charged_notification_label">اعلان شارژ کامل</string>
<string name="settings_charged_notification_description">پس از اینکه همه چیزهایی که در حال شارژ هستند به سطح شارژ هدف رسیدند، یک اعلان نمایش داده می‌شود.</string>
<string name="settings_charged_threshold_label">سطح شارژ هدف</string>
<string name="settings_charged_scope_label">باتری‌های تحت نظارت</string>
<string name="settings_charged_scope_pods_label">فقط پادها</string>
<string name="settings_charged_scope_case_label">فقط کیس</string>
<string name="settings_charged_scope_both_label">پادها و کیس</string>
<string name="device_settings_experimental_title">ویژگی آزمایشی</string>
<string name="device_settings_experimental_description">این ویژگی هنوز روی همه دستگاه‌ها آزمایش نشده است. اگر چیزی درست کار نمی‌کند، لطفاً یک گزارش خطا با لاگ دیباگ باز کنید.</string>
<string name="device_settings_experimental_action">باز کردن ردیاب مشکلات</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">اشکال‌زدایی</string>
<string name="settings_general_label">تنظیمات</string>
<string name="settings_general_description">تنظیمات کلی که بر کل برنامه تأثیر می‌گذارند.</string>
<string name="settings_overview_hide_unmatched_label">پنهان کردن دستگاه‌های نامتناسب</string>
<string name="settings_overview_hide_unmatched_description">دستگاه‌های نزدیکی که با هیچ‌کدام از پروفایل‌های شما تطابق ندارند در نمای کلی نمایش داده نمی‌شوند، مثلاً AirPods دیگران.</string>
<string name="device_battery_estimate_toggle_label">برآورد زمان باتری</string>
<string name="device_battery_estimate_card_desc">یاد بگیرید که این دستگاه چگونه شارژ و تخلیه می‌شود تا زمان باقی‌مانده، زمان تا پر شدن و سلامت باتری را تخمین بزنید.</string>
<string name="device_battery_estimate_reset_action">بازنشانی داده‌های یادگیری</string>
<string name="device_battery_estimate_reset_desc">شروع مجدد از عمر اسمی باتری.</string>
<string name="device_battery_estimate_reset_confirm_title">بازنشانی داده‌های یادگیری‌شده؟</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod داده‌های اندازه‌گیری‌شده این دستگاه (تخلیه، سرعت شارژ و سلامت باتری) را فراموش خواهد کرد و دوباره از عمر اسمی باتری شروع خواهد کرد.</string>
<string name="settings_acknowledgements_label">قدردانی‌ها</string>
<string name="settings_debug_autoreports_label">گزارش‌های خطای خودکار</string>
<string name="settings_debug_autoreports_description">مشکلات را به‌طور خودکار گزارش می‌دهد، مثلاً جزئیات مربوط به خرابی برنامه تا بتوانم نحوه رفع آن را بفهمم.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">شفافیت پس‌زمینه</string>
<string name="widget_config_show_device_label">نمایش نام دستگاه</string>
<string name="widget_config_reset_label">بازنشانی به مقادیر پیش‌فرض</string>
<string name="widget_config_preview_resize_hint">می‌توانید پس از قرار دادن ویجت روی صفحه اصلی، اندازه آن را تغییر دهید.</string>
<string name="widget_config_preview_resize_hint">شما می‌توانید ابزارک را پس از قرار دادن آن در صفحه اصلی تغییر اندازه دهید.</string>
<string name="widget_config_custom_label">سفارشی</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">تیره</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">بلوتوث غیرفعال است، آن را فعال کنید ;)</string>
<string name="overview_monitoring_active_label">نظارت بر دستگاه‌ها</string>
<string name="overview_monitoring_active_description">مطمئن شوید دستگاه شما نزدیک و فعال است.</string>
<string name="overview_troubleshoot_suggestion_label">متصل است، اما داده‌ای وجود ندارد</string>
<string name="overview_troubleshoot_suggestion_description">دستگاه شما متصل است، اما CAPod هیچ داده زنده‌ای از آن دریافت نمی‌کند. ممکن است تلفن شما به یک گزینه سازگاری نیاز داشته باشد — عیب‌یاب می‌تواند به‌طور خودکار تلاش کند آن را پیدا کند.</string>
<string name="overview_troubleshoot_suggestion_action">اجرای عیب‌یاب</string>
<string name="overview_unmatched_devices_label">دستگاه‌های بدون تطابق</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d دستگاه بدون پروفایل منطبق</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">آخرین مشاهده: %s</string>
<string name="first_seen_x">اولین مشاهده: %s</string>
<string name="battery_cached_label">آخرین · %s</string>
<string name="battery_time_remaining_format_hm">%1$d س %2$d د</string>
<string name="battery_time_remaining_format_h">%1$d س</string>
<string name="battery_time_remaining_format_m">%1$d د</string>
<string name="permission_post_notifications_label">نمایش اعلان‌ها</string>
<string name="permission_post_notifications_description">"به CAPod اجازه دهید اعلان‌هایی درباره AirPods شما نشان دهد، مثلاً وضعیت فعلی آن‌ها هنگام اتصال."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">شماره سریال پاد راست</string>
<string name="device_settings_info_left_bonded_label">چپ متصل</string>
<string name="device_settings_info_right_bonded_label">راست متصل</string>
<string name="device_settings_info_battery_health_label">سلامت باتری (تخمینی)</string>
<string name="device_settings_info_battery_health_left_label">سلامت باتری سمت چپ (تخمینی)</string>
<string name="device_settings_info_battery_health_right_label">سلامت باتری سمت راست (تخمینی)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">هنوز درحال تعیین است، بعدا دوباره مراجعه کنید</string>
<string name="device_settings_info_details_label">جزئیات دستگاه</string>
<string name="device_settings_info_details_action">نمایش جزئیات دستگاه</string>
<string name="device_settings_info_status_label">وضعیت</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">تشخیص خواب</string>
<string name="reaction_sleep_notification_title">توسط تشخیص خواب متوقف شد</string>
<string name="reaction_sleep_notification_text">%1$s گزارش داد که به خواب رفتید، بنابراین موسیقی شما متوقف شد. می‌توانید این را در تنظیمات دستگاه غیرفعال کنید.</string>
<string name="reaction_charged_channel_label">اعلان شارژ کامل</string>
<string name="reaction_charged_notification_title">شارژ کامل شد</string>
<string name="reaction_charged_notification_text_full">%1$s کاملاً شارژ شده است.</string>
<string name="reaction_charged_notification_text_partial">%1$s تا %2$d%% شارژ شده است.</string>
<string name="device_settings_rename_label">تغییر نام</string>
<string name="device_settings_rename_hint">نام دستگاه</string>
<string name="device_settings_rename_confirm">تغییر نام</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Näytä yhteyden ponnahdusikkuna</string>
<string name="settings_popup_connected_description">Näytä ponnahdusikkuna, kun laite yhdistetään ensimmäisen kerran.</string>
<string name="settings_popup_info_not_in_app">Ponnahdusikkunat näytetään vain, kun et ole sovelluksessa.</string>
<string name="settings_charged_notification_label">Latausilmoitus</string>
<string name="settings_charged_notification_description">Näytä ilmoitus, kun kaikki ladattavat laitteet saavuttavat tavoitelataustason.</string>
<string name="settings_charged_threshold_label">Tavoitelataustaso</string>
<string name="settings_charged_scope_label">Seurattavat akut</string>
<string name="settings_charged_scope_pods_label">Vain nappikuulokkeet</string>
<string name="settings_charged_scope_case_label">Vain kotelo</string>
<string name="settings_charged_scope_both_label">Nappikuulokkeet ja kotelo</string>
<string name="device_settings_experimental_title">Kokeellinen ominaisuus</string>
<string name="device_settings_experimental_description">Tätä ominaisuutta ei ole vielä testattu kaikilla laitteilla. Jos jokin ei toimi oikein, avaa ongelmaraportti virheenkorjauslokilla.</string>
<string name="device_settings_experimental_action">Avaa ongelmaseuranta</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Virheenjäljitys</string>
<string name="settings_general_label">Asetukset</string>
<string name="settings_general_description">Yleiset säädöt, jotka vaikuttavat koko sovellukseen.</string>
<string name="settings_overview_hide_unmatched_label">Piilota yhteensopimattomat laitteet</string>
<string name="settings_overview_hide_unmatched_description">Älä näytä lähistöllä olevia laitteita, jotka eivät vastaa mitään profiileistasi yleiskatsauksessa, esim. muiden ihmisten AirPodseja.</string>
<string name="device_battery_estimate_toggle_label">Akun arviot</string>
<string name="device_battery_estimate_card_desc">Opi kuinka tämä laite latautuu ja kulutetaan, jotta voit arvioida jäljellä olevaa aikaa, latautumiseen kuluvaa aikaa ja akun kuntoa.</string>
<string name="device_battery_estimate_reset_action">Nollaa opitut tiedot</string>
<string name="device_battery_estimate_reset_desc">Aloita alusta nimellisakun perusteella.</string>
<string name="device_battery_estimate_reset_confirm_title">Nollataan opitut tiedot?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod unohtaa tämän laitteen mitatun kulutuksen, latausnopeuden ja akun kunnon, ja aloittaa alusta nimellisakun perusteella.</string>
<string name="settings_acknowledgements_label">Kiitokset</string>
<string name="settings_debug_autoreports_label">Automaattiset virheraportit</string>
<string name="settings_debug_autoreports_description">Raportoi ongelmat automaattisesti, esim. tiedot sovelluksen kaatumisesta, jotta voin selvittää, miten se korjataan.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Taustan läpinäkyvyys</string>
<string name="widget_config_show_device_label">Näytä laitteen nimi</string>
<string name="widget_config_reset_label">Palauta oletusasetukset</string>
<string name="widget_config_preview_resize_hint">Voit muuttaa widgetin kokoa sen asettamisen jälkeen aloitusnäytöllä.</string>
<string name="widget_config_preview_resize_hint">Voit muuttaa widgetin kokoa sen jälkeen, kun olet asettanut sen kotinäytöllesi.</string>
<string name="widget_config_custom_label">Mukautettu</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Tumma</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth on pois käytöstä, ota se käyttöön ;)</string>
<string name="overview_monitoring_active_label">Etsitään laitteita</string>
<string name="overview_monitoring_active_description">Varmista, että laitteesi on lähellä ja aktiivinen.</string>
<string name="overview_troubleshoot_suggestion_label">Yhdistetty, mutta ei dataa</string>
<string name="overview_troubleshoot_suggestion_description">Laitteesi on yhdistetty, mutta CAPod ei vastaanota siitä reaaliaikaista dataa. Puhelimesi saattaa tarvita yhteensopivuusasetuksen — vianmääritys voi yrittää löytää sellaisen automaattisesti.</string>
<string name="overview_troubleshoot_suggestion_action">Käynnistä vianmääritys</string>
<string name="overview_unmatched_devices_label">Vastaavuudetta olevat laitteet</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d laite ilman vastaavaa profiilia</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Viimeksi nähty: %s</string>
<string name="first_seen_x">Ensimmäisen kerran nähty: %s</string>
<string name="battery_cached_label">Viimeksi nähty · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Näytä ilmoitukset</string>
<string name="permission_post_notifications_description">"Salli CAPod-sovelluksen näyttää ilmoituksia AirPodeistasi, esim. niiden nykyinen tila yhdistettynä."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Oikean kuulokkeen sarjanumero</string>
<string name="device_settings_info_left_bonded_label">Vasen yhdistetty</string>
<string name="device_settings_info_right_bonded_label">Oikea yhdistetty</string>
<string name="device_settings_info_battery_health_label">Akun kunto (arvioitu)</string>
<string name="device_settings_info_battery_health_left_label">Vasemman akun kunto (arvioitu)</string>
<string name="device_settings_info_battery_health_right_label">Oikean akun kunto (arvioitu)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Vielä määritteillään, tarkista myöhemmin</string>
<string name="device_settings_info_details_label">Laitteen tiedot</string>
<string name="device_settings_info_details_action">Näytä laitteen tiedot</string>
<string name="device_settings_info_status_label">Tila</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Unitunnistus</string>
<string name="reaction_sleep_notification_title">Unitunnistus keskeytti toiston</string>
<string name="reaction_sleep_notification_text">%1$s havaitsi sinun nukahtaneen, joten musiikkisi keskeytettiin. Voit poistaa tämän käytöstä laiteasetuksista.</string>
<string name="reaction_charged_channel_label">Latausilmoitus</string>
<string name="reaction_charged_notification_title">Lataus valmis</string>
<string name="reaction_charged_notification_text_full">%1$s on täyteen ladattu.</string>
<string name="reaction_charged_notification_text_partial">%1$s on ladattu %2$d%%:iin.</string>
<string name="device_settings_rename_label">Nimeä uudelleen</string>
<string name="device_settings_rename_hint">Laitteen nimi</string>
<string name="device_settings_rename_confirm">Nimeä uudelleen</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Ipakita ang connection popup</string>
<string name="settings_popup_connected_description">Magpakita ng popup kapag unang kumonekta ang device.</string>
<string name="settings_popup_info_not_in_app">Ang mga popup ay ipinapakita lamang kapag wala ka sa app.</string>
<string name="settings_charged_notification_label">Notipikasyon ng pagkakarga</string>
<string name="settings_charged_notification_description">Magpakita ng notipikasyon kapag ang lahat ng nagcha-charge ay umabot na sa target na antas ng karga.</string>
<string name="settings_charged_threshold_label">Target na antas ng karga</string>
<string name="settings_charged_scope_label">Mga sinusubaybaygang baterya</string>
<string name="settings_charged_scope_pods_label">Mga pod lamang</string>
<string name="settings_charged_scope_case_label">Case lamang</string>
<string name="settings_charged_scope_both_label">Mga pod at case</string>
<string name="device_settings_experimental_title">Pang-eksperimentong tampok</string>
<string name="device_settings_experimental_description">Ang tampok na ito ay hindi pa nasubok sa lahat ng device. Kung may hindi gumagana nang tama, mangyaring magbukas ng isyu na may debug log.</string>
<string name="device_settings_experimental_action">Buksan ang Issue Tracker</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Debug</string>
<string name="settings_general_label">Mga Setting</string>
<string name="settings_general_description">Mga pangkalahatang pag-aayos na nakakaapekto sa buong app.</string>
<string name="settings_overview_hide_unmatched_label">Itago ang mga hindi tugmang device</string>
<string name="settings_overview_hide_unmatched_description">Huwag ipakita ang mga kalapit na device na hindi tugma sa alinman sa iyong mga profile sa pangkalahatang-tanaw, hal. ang mga AirPods ng ibang tao.</string>
<string name="device_battery_estimate_toggle_label">Mga Pagtataya ng Baterya</string>
<string name="device_battery_estimate_card_desc">Alamin kung paano nag-charge at gumagamit ng kuryente ang device na ito upang matantya ang natitirang oras, oras hanggang sa puno, at kalusugan ng baterya.</string>
<string name="device_battery_estimate_reset_action">Burahin ang natutunan na datos</string>
<string name="device_battery_estimate_reset_desc">Magsimula muli mula sa rated na buhay ng baterya.</string>
<string name="device_battery_estimate_reset_confirm_title">Burahin ang natutunan na datos?</string>
<string name="device_battery_estimate_reset_confirm_message">Makakalimutan ng CAPod ang sinusukat na paggamit ng kuryente, bilis ng pag-charge, at kalusugan ng baterya ng device na ito, at magsisimula muli mula sa rated na buhay ng baterya.</string>
<string name="settings_acknowledgements_label">Mga Pasasalamat</string>
<string name="settings_debug_autoreports_label">Mga awtomatikong ulat ng bug</string>
<string name="settings_debug_autoreports_description">Awtomatikong nag-uulat ng mga isyu, hal. mga detalye sa isang pag-crash ng app para malaman ko kung paano ito ayusin.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Transparency ng background</string>
<string name="widget_config_show_device_label">Ipakita ang pangalan ng device</string>
<string name="widget_config_reset_label">I-reset sa mga default</string>
<string name="widget_config_preview_resize_hint">Maaari mong baguhin ang laki ng widget pagkatapos ilagay ito sa iyong home screen.</string>
<string name="widget_config_preview_resize_hint">Maaari mong baguhin ang laki ng widget pagkatapos itong ilagay sa iyong home screen.</string>
<string name="widget_config_custom_label">Pasadyang</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Madilim</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Naka-disable ang Bluetooth, i-enable ito ;)</string>
<string name="overview_monitoring_active_label">Nagmo-monitor ng mga device</string>
<string name="overview_monitoring_active_description">Siguraduhing malapit at aktibo ang iyong device.</string>
<string name="overview_troubleshoot_suggestion_label">Nakakonekta, ngunit walang data</string>
<string name="overview_troubleshoot_suggestion_description">Nakakonekta ang iyong device, ngunit hindi nakakatanggap ng live data ang CAPod mula rito. Maaaring kailanganin ng iyong telepono ng opsyon sa compatibility — maaaring subukang hanapin ito nang awtomatiko ng troubleshooter.</string>
<string name="overview_troubleshoot_suggestion_action">Patakbuhin ang troubleshooter</string>
<string name="overview_unmatched_devices_label">Mga hindi nakatugmang device</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d device na walang katugmang profile</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Huling nakita: %s</string>
<string name="first_seen_x">Unang nakita: %s</string>
<string name="battery_cached_label">Huling kilala · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$d h</string>
<string name="battery_time_remaining_format_m">%1$d m</string>
<string name="permission_post_notifications_label">Ipakita ang mga notipikasyon</string>
<string name="permission_post_notifications_description">"Payagan ang CAPod na magpakita ng mga notipikasyon tungkol sa iyong AirPods, hal. ang kanilang kasalukuyang status habang konektado."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Serial ng Kanang Pod</string>
<string name="device_settings_info_left_bonded_label">Kaliwang Naka-bond</string>
<string name="device_settings_info_right_bonded_label">Kanang Naka-bond</string>
<string name="device_settings_info_battery_health_label">Kalusugan ng Baterya (tinantya)</string>
<string name="device_settings_info_battery_health_left_label">Kalusugan ng Kaliwang Baterya (tinantya.)</string>
<string name="device_settings_info_battery_health_right_label">Kalusugan ng Kananang Baterya (tinantya.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Patuloy na tinutukoy, bumalik mamaya</string>
<string name="device_settings_info_details_label">Mga Detalye ng Device</string>
<string name="device_settings_info_details_action">Ipakita ang mga detalye ng device</string>
<string name="device_settings_info_status_label">Katayuan</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Pagtuklas ng Pagtulog</string>
<string name="reaction_sleep_notification_title">Naka-pause ng Pagtuklas ng Pagtulog</string>
<string name="reaction_sleep_notification_text">Naiulat ng %1$s na nakatulog ka, kaya na-pause ang iyong musika. Maaari mong i-disable ito sa mga setting ng device.</string>
<string name="reaction_charged_channel_label">Notipikasyon ng Pagkakarga</string>
<string name="reaction_charged_notification_title">Kumpleto na ang pag-charge</string>
<string name="reaction_charged_notification_text_full">Ganap na na-charge ang %1$s.</string>
<string name="reaction_charged_notification_text_partial">Na-charge na ang %1$s sa %2$d%%.</string>
<string name="device_settings_rename_label">Palitan ang Pangalan</string>
<string name="device_settings_rename_hint">Pangalan ng device</string>
<string name="device_settings_rename_confirm">Palitan ang Pangalan</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Afficher une fenêtre de connection</string>
<string name="settings_popup_connected_description">Afficher une fenêtre contextuelle quand lappareil se connecte pour la première fois.</string>
<string name="settings_popup_info_not_in_app">Les popups ne sont affichées que lorsque vous n\'êtes pas dans l\'application.</string>
<string name="settings_charged_notification_label">Notification de charge complète</string>
<string name="settings_charged_notification_description">Afficher une notification une fois que tout ce qui est en charge atteint le niveau de charge cible.</string>
<string name="settings_charged_threshold_label">Niveau de charge cible</string>
<string name="settings_charged_scope_label">Batteries surveillées</string>
<string name="settings_charged_scope_pods_label">Écouteurs uniquement</string>
<string name="settings_charged_scope_case_label">Boîtier uniquement</string>
<string name="settings_charged_scope_both_label">Écouteurs et boîtier</string>
<string name="device_settings_experimental_title">Fonctionnalité expérimentale</string>
<string name="device_settings_experimental_description">Cette fonctionnalité n\'a pas encore été testée sur tous les appareils. Si quelque chose ne fonctionne pas correctement, veuillez ouvrir un ticket avec un journal de débogage.</string>
<string name="device_settings_experimental_action">Ouvrir le suivi des problèmes</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Débogage</string>
<string name="settings_general_label">Paramètres</string>
<string name="settings_general_description">Réglage généraux qui affectent l\'appli entière.</string>
<string name="settings_overview_hide_unmatched_label">Masquer les appareils sans correspondance</string>
<string name="settings_overview_hide_unmatched_description">Ne pas afficher dans laperçu les appareils à proximité qui ne correspondent à aucun de vos profils, p. ex. les AirPods dautres personnes.</string>
<string name="device_battery_estimate_toggle_label">Estimations batterie</string>
<string name="device_battery_estimate_card_desc">Découvrez comment cet appareil se charge et se décharge pour estimer le temps restant, le temps jusqu’à la charge complète et l’état de la batterie.</string>
<string name="device_battery_estimate_reset_action">Réinitialiser les données apprises</string>
<string name="device_battery_estimate_reset_desc">Recommencer à partir de lautonomie nominale de la batterie.</string>
<string name="device_battery_estimate_reset_confirm_title">Réinitialiser les données apprises ?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod oubliera la consommation mesurée de cet appareil, sa vitesse de charge et l’état de sa batterie, et recommencera à partir de lautonomie nominale de la batterie.</string>
<string name="settings_acknowledgements_label">Remerciements</string>
<string name="settings_debug_autoreports_label">Relevés de bogue automatiques</string>
<string name="settings_debug_autoreports_description">Signale automatiquement les problèmes, p. ex., les détails dun plantage de lappli, afin que je puisse essayer de les corriger.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Transparence de l\'arrière-plan</string>
<string name="widget_config_show_device_label">Afficher le nom de lappareil</string>
<string name="widget_config_reset_label">Rétablir les valeurs par défaut</string>
<string name="widget_config_preview_resize_hint">Vous pouvez redimensionner le widget après l\'avoir placé sur votre écran d\'accueil.</string>
<string name="widget_config_preview_resize_hint">Vous pouvez redimensionner le widget après lavoir placé sur votre écran daccueil.</string>
<string name="widget_config_custom_label">Personnalisée</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Sombre</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth est désactivé, activez-le ;)</string>
<string name="overview_monitoring_active_label">Surveillance des appareils</string>
<string name="overview_monitoring_active_description">Assurez-vous que votre appareil est proche et actif.</string>
<string name="overview_troubleshoot_suggestion_label">Connecté, mais aucune donnée</string>
<string name="overview_troubleshoot_suggestion_description">Votre appareil est connecté, mais CAPod ne reçoit aucune donnée en direct de sa part. Votre téléphone a peut-être besoin dune option de compatibilité — le dépannage peut essayer den trouver une automatiquement.</string>
<string name="overview_troubleshoot_suggestion_action">Lancer le dépannage</string>
<string name="overview_unmatched_devices_label">Appareils sans correspondance</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d appareil sans profil correspondant</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Dernière connection : %s</string>
<string name="first_seen_x">Première connexion : %s</string>
<string name="battery_cached_label">Dernière fois vu · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Afficher les notifications</string>
<string name="permission_post_notifications_description">"Permettre à CAPod dafficher des notifications au sujet des AirPods, p. ex. leur état actuel quand ils sont connectés."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Numéro de série de l’écouteur droit</string>
<string name="device_settings_info_left_bonded_label">Gauche appairé</string>
<string name="device_settings_info_right_bonded_label">Droite appairée</string>
<string name="device_settings_info_battery_health_label">Santé de la batterie (estimée)</string>
<string name="device_settings_info_battery_health_left_label">Santé de la batterie gauche (est.)</string>
<string name="device_settings_info_battery_health_right_label">Santé de la batterie droite (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">En cours de détermination, revenez plus tard</string>
<string name="device_settings_info_details_label">Détails de lappareil</string>
<string name="device_settings_info_details_action">Afficher les détails de lappareil</string>
<string name="device_settings_info_status_label">État</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Détection du sommeil</string>
<string name="reaction_sleep_notification_title">Mis en pause par la détection du sommeil</string>
<string name="reaction_sleep_notification_text">%1$s a détecté que vous vous êtes endormi, votre musique a donc été mise en pause. Vous pouvez désactiver cela dans les paramètres de l\'appareil.</string>
<string name="reaction_charged_channel_label">Notification de charge</string>
<string name="reaction_charged_notification_title">Charge terminée</string>
<string name="reaction_charged_notification_text_full">%1$s est complètement chargé.</string>
<string name="reaction_charged_notification_text_partial">%1$s a été chargé à %2$d%%.</string>
<string name="device_settings_rename_label">Renommer</string>
<string name="device_settings_rename_hint">Nom de lappareil</string>
<string name="device_settings_rename_confirm">Renommer</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">Mostrar ventá emerxente de conexión</string>
<string name="settings_popup_connected_description">Mostrar unha ventá emerxente cando o dispositivo se conecta por primeira vez.</string>
<string name="settings_popup_info_not_in_app">As ventás emerxentes só se mostran cando non estás na aplicación.</string>
<string name="settings_charged_notification_label">Notificación de carga completa</string>
<string name="settings_charged_notification_description">Mostra unha notificación cando todo o que está cargando alcanza o nivel de carga obxectivo.</string>
<string name="settings_charged_threshold_label">Nivel de carga obxectivo</string>
<string name="settings_charged_scope_label">Baterías monitorizadas</string>
<string name="settings_charged_scope_pods_label">Só os auriculares</string>
<string name="settings_charged_scope_case_label">Só o estoxo</string>
<string name="settings_charged_scope_both_label">Auriculares e estoxo</string>
<string name="device_settings_experimental_title">Función experimental</string>
<string name="device_settings_experimental_description">Esta función aínda non foi probada en todos os dispositivos. Se algo non funciona correctamente, abre un problema co rexistro de depuración.</string>
<string name="device_settings_experimental_action">Abrir o rastreador de problemas</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">Depuración</string>
<string name="settings_general_label">Configuración</string>
<string name="settings_general_description">Axustes xerais que afectan a toda a aplicación.</string>
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos non coincidentes</string>
<string name="settings_overview_hide_unmatched_description">Non mostrar dispositivos próximos que non coincidan con ningún dos teus perfís na vista xeral, p. ex. os AirPods doutras persoas.</string>
<string name="device_battery_estimate_toggle_label">Estimacións de batería</string>
<string name="device_battery_estimate_card_desc">Aprende como este dispositivo se carga e se descarrega para estimar o tempo restante, o tempo ata estar cargado e a saúde da batería.</string>
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
<string name="device_battery_estimate_reset_desc">Comezar de novo desde a vida útil da batería indicada.</string>
<string name="device_battery_estimate_reset_confirm_title">Restablecer datos aprendidos?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod esquecerá a drenaxe medida deste dispositivo, a velocidade de carga e a saúde da batería, e comezará de novo desde a vida útil da batería indicada.</string>
<string name="settings_acknowledgements_label">Agradecementos</string>
<string name="settings_debug_autoreports_label">Informes de erros automáticos</string>
<string name="settings_debug_autoreports_description">Informa automaticamente de problemas, p. ex., detalles sobre un fallo da aplicación para que poida descubrir como solucionalo.</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">Transparencia de fondo</string>
<string name="widget_config_show_device_label">Mostrar nome do dispositivo</string>
<string name="widget_config_reset_label">Restablecer aos valores predeterminados</string>
<string name="widget_config_preview_resize_hint">Podes cambiar o tamaño do widget despois de colocalo na pantalla de inicio.</string>
<string name="widget_config_preview_resize_hint">Podes redimensionar o widget despois de colocalo na túa pantalla de inicio.</string>
<string name="widget_config_custom_label">Personalizado</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Escuro</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">O Bluetooth está desactivado, actívao ;)</string>
<string name="overview_monitoring_active_label">Supervisando dispositivos</string>
<string name="overview_monitoring_active_description">Asegúrate de que o teu dispositivo estea preto e activo.</string>
<string name="overview_troubleshoot_suggestion_label">Conectado, pero sen datos</string>
<string name="overview_troubleshoot_suggestion_description">O teu dispositivo está conectado, pero CAPod non está a recibir datos en tempo real del. O teu teléfono pode necesitar unha opción de compatibilidade — o solucionador de problemas pode tentar atopala automaticamente.</string>
<string name="overview_troubleshoot_suggestion_action">Executar o solucionador de problemas</string>
<string name="overview_unmatched_devices_label">Dispositivos sen perfil coincidente</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d dispositivo sen perfil coincidente</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">Visto por última vez: %s</string>
<string name="first_seen_x">Visto por primeira vez: %s</string>
<string name="battery_cached_label">Último coñecido · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">Mostrar notificacións</string>
<string name="permission_post_notifications_description">"Permitir que CAPod mostre notificacións sobre os teus AirPods, p. ex., o seu estado actual mentres están conectados."</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">Número de serie do auricular dereito</string>
<string name="device_settings_info_left_bonded_label">Esquerdo vinculado</string>
<string name="device_settings_info_right_bonded_label">Dereito vinculado</string>
<string name="device_settings_info_battery_health_label">Saúde da batería (estimada)</string>
<string name="device_settings_info_battery_health_left_label">Saúde da batería esquerda (est.)</string>
<string name="device_settings_info_battery_health_right_label">Saúde da batería dereita (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">Aínda se está determinando, volve máis tarde</string>
<string name="device_settings_info_details_label">Detalles do dispositivo</string>
<string name="device_settings_info_details_action">Mostrar detalles do dispositivo</string>
<string name="device_settings_info_status_label">Estado</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">Detección de sono</string>
<string name="reaction_sleep_notification_title">Pausado pola detección de sono</string>
<string name="reaction_sleep_notification_text">%1$s detectou que te durmiches, polo que a túa música foi pausada. Podes desactivar isto na configuración do dispositivo.</string>
<string name="reaction_charged_channel_label">Notificación de carga</string>
<string name="reaction_charged_notification_title">Carga completa</string>
<string name="reaction_charged_notification_text_full">%1$s está completamente cargado.</string>
<string name="reaction_charged_notification_text_partial">%1$s foi cargado ata o %2$d%%.</string>
<string name="device_settings_rename_label">Renomear</string>
<string name="device_settings_rename_hint">Nome do dispositivo</string>
<string name="device_settings_rename_confirm">Renomear</string>
+31 -1
View File
@@ -76,6 +76,13 @@
<string name="settings_popup_connected_label">कनेक्शन पॉपअप दिखाएं</string>
<string name="settings_popup_connected_description">डिवाइस पहली बार कनेक्ट होने पर एक पॉपअप दिखाएं।</string>
<string name="settings_popup_info_not_in_app">पॉपअप केवल तब दिखाए जाते हैं जब आप ऐप में नहीं होते।</string>
<string name="settings_charged_notification_label">चार्ज सूचना</string>
<string name="settings_charged_notification_description">एक बार जब चार्ज हो रही हर चीज़ लक्ष्य चार्ज स्तर तक पहुँच जाए, तब एक सूचना दिखाएँ।</string>
<string name="settings_charged_threshold_label">लक्ष्य चार्ज स्तर</string>
<string name="settings_charged_scope_label">निगरानी की जा रही बैटरियाँ</string>
<string name="settings_charged_scope_pods_label">केवल पॉड्स</string>
<string name="settings_charged_scope_case_label">केवल केस</string>
<string name="settings_charged_scope_both_label">पॉड्स और केस</string>
<string name="device_settings_experimental_title">प्रयोगात्मक फ़ीचर</string>
<string name="device_settings_experimental_description">इस फ़ीचर को अभी सभी डिवाइस पर नहीं परखा गया है। यदि कुछ सही तरह से काम नहीं कर रहा, तो कृपया डीबग लॉग के साथ एक समस्या खोलें।</string>
<string name="device_settings_experimental_action">इश्यू ट्रैकर खोलें</string>
@@ -113,6 +120,14 @@
<string name="settings_category_debug_label">डीबग</string>
<string name="settings_general_label">सेटिंग्स</string>
<string name="settings_general_description">सामान्य समायोजन जो पूरे ऐप को प्रभावित करते हैं।</string>
<string name="settings_overview_hide_unmatched_label">बेमेल डिवाइस छुपाएँ</string>
<string name="settings_overview_hide_unmatched_description">ओवरव्यू में उन नज़दीकी डिवाइस न दिखाएँ जो आपकी किसी प्रोफ़ाइल से मेल नहीं खाते, जैसे दूसरों के AirPods।</string>
<string name="device_battery_estimate_toggle_label">बैटरी अनुमान</string>
<string name="device_battery_estimate_card_desc">यह सीखें कि यह डिवाइस कैसे चार्ज होता है और खत्म होता है ताकि शेष समय, चार्ज होने तक का समय और बैटरी स्वास्थ्य का अनुमान लगाया जा सके।</string>
<string name="device_battery_estimate_reset_action">सीखे गए डेटा को रीसेट करें</string>
<string name="device_battery_estimate_reset_desc">रेटेड बैटरी जीवन से फिर से शुरू करें।</string>
<string name="device_battery_estimate_reset_confirm_title">सीखे गए डेटा को रीसेट करें?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod इस डिवाइस की मापी गई खपत, चार्जिंग गति और बैटरी स्वास्थ्य को भूल जाएगा, और रेटेड बैटरी जीवन से फिर से शुरू करेगा।</string>
<string name="settings_acknowledgements_label">स्वीकृतियाँ</string>
<string name="settings_debug_autoreports_label">स्वचालित बग रिपोर्ट</string>
<string name="settings_debug_autoreports_description">समस्याओं की स्वचालित रूप से रिपोर्ट करता है, जैसे ऐप क्रैश पर विवरण ताकि मैं यह पता लगा सकूं कि इसे कैसे ठीक किया जाए।</string>
@@ -147,7 +162,7 @@
<string name="widget_config_transparency_label">पृष्ठभूमि पारदर्शिता</string>
<string name="widget_config_show_device_label">डिवाइस का नाम दिखाएं</string>
<string name="widget_config_reset_label">डिफ़ॉल्ट पर रीसेट करें</string>
<string name="widget_config_preview_resize_hint">आप होम स्क्रीन पर रखने के बाद विजेट का आकार बदल सकते हैं।</string>
<string name="widget_config_preview_resize_hint">होम स्क्रीन पर विजेट रखने के बाद आप इसका आकार बदल सकते हैं।</string>
<string name="widget_config_custom_label">कस्टम</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">गहरा</string>
@@ -193,6 +208,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth अक्षम है, इसे सक्षम करें ;)</string>
<string name="overview_monitoring_active_label">डिवाइसों की निगरानी हो रही है</string>
<string name="overview_monitoring_active_description">सुनिश्चित करें कि आपका डिवाइस पास में और सक्रिय है।</string>
<string name="overview_troubleshoot_suggestion_label">कनेक्टेड, लेकिन कोई डेटा नहीं</string>
<string name="overview_troubleshoot_suggestion_description">आपका डिवाइस कनेक्टेड है, लेकिन CAPod उससे कोई लाइव डेटा प्राप्त नहीं कर रहा। आपके फ़ोन को एक संगतता विकल्प की ज़रूरत हो सकती है — समस्या निवारक इसे स्वचालित रूप से खोजने की कोशिश कर सकता है।</string>
<string name="overview_troubleshoot_suggestion_action">समस्या निवारक चलाएँ</string>
<string name="overview_unmatched_devices_label">बेमेल डिवाइस</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d डिवाइस बिना मेल खाते प्रोफ़ाइल के</item>
@@ -266,6 +284,9 @@
<string name="last_seen_x">अंतिम बार देखा: %s</string>
<string name="first_seen_x">पहली बार देखा: %s</string>
<string name="battery_cached_label">अंतिम ज्ञात · %s</string>
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
<string name="battery_time_remaining_format_h">%1$dh</string>
<string name="battery_time_remaining_format_m">%1$dm</string>
<string name="permission_post_notifications_label">सूचनाएं दिखाएं</string>
<string name="permission_post_notifications_description">"CAPod को आपके AirPods के बारे में सूचनाएं दिखाने की अनुमति दें, जैसे कनेक्ट होने पर उनकी वर्तमान स्थिति।"</string>
<!-- Device profiles -->
@@ -411,6 +432,11 @@
<string name="device_settings_info_right_serial_label">दाएं पॉड का सीरियल नंबर</string>
<string name="device_settings_info_left_bonded_label">बायाँ युग्मित</string>
<string name="device_settings_info_right_bonded_label">दायाँ युग्मित</string>
<string name="device_settings_info_battery_health_label">बैटरी स्वास्थ्य (अनुमानित)</string>
<string name="device_settings_info_battery_health_left_label">बाईं बैटरी स्वास्थ्य (अनुमानित)</string>
<string name="device_settings_info_battery_health_right_label">दाईं बैटरी स्वास्थ्य (अनुमानित)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_pending">अभी निर्धारण हो रहा है, बाद में फिर से जांचें</string>
<string name="device_settings_info_details_label">डिवाइस विवरण</string>
<string name="device_settings_info_details_action">डिवाइस विवरण दिखाएं</string>
<string name="device_settings_info_status_label">स्थिति</string>
@@ -490,6 +516,10 @@
<string name="reaction_sleep_channel_label">नींद का पता लगाना</string>
<string name="reaction_sleep_notification_title">नींद का पता लगाने से रोका गया</string>
<string name="reaction_sleep_notification_text">%1$s ने बताया कि आप सो गए, इसलिए आपका संगीत रोक दिया गया। आप इसे डिवाइस सेटिंग्स में बंद कर सकते हैं।</string>
<string name="reaction_charged_channel_label">चार्ज सूचना</string>
<string name="reaction_charged_notification_title">चार्जिंग पूर्ण</string>
<string name="reaction_charged_notification_text_full">%1$s पूरी तरह चार्ज हो गया है।</string>
<string name="reaction_charged_notification_text_partial">%1$s को %2$d%% तक चार्ज किया जा चुका है।</string>
<string name="device_settings_rename_label">नाम बदलें</string>
<string name="device_settings_rename_hint">डिवाइस का नाम</string>
<string name="device_settings_rename_confirm">नाम बदलें</string>

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